diff --git a/api/src/main/kotlin/nebulosa/api/image/ImageController.kt b/api/src/main/kotlin/nebulosa/api/image/ImageController.kt index 17ac495d2..ee5d59398 100644 --- a/api/src/main/kotlin/nebulosa/api/image/ImageController.kt +++ b/api/src/main/kotlin/nebulosa/api/image/ImageController.kt @@ -5,8 +5,6 @@ import jakarta.validation.Valid import nebulosa.api.atlas.Location import nebulosa.api.beans.converters.device.DeviceOrEntityParam import nebulosa.api.beans.converters.location.LocationParam -import nebulosa.image.algorithms.transformation.ProtectionMethod -import nebulosa.image.format.ImageChannel import nebulosa.indi.device.camera.Camera import nebulosa.star.detection.ImageStar import org.hibernate.validator.constraints.Range @@ -20,32 +18,13 @@ class ImageController( private val imageService: ImageService, ) { - @GetMapping + @PostMapping fun openImage( @RequestParam path: Path, @DeviceOrEntityParam(required = false) camera: Camera?, - @RequestParam(required = false, defaultValue = "true") debayer: Boolean, - @RequestParam(required = false, defaultValue = "false") calibrate: Boolean, - @RequestParam(required = false, defaultValue = "false") force: Boolean, - @RequestParam(required = false, defaultValue = "false") autoStretch: Boolean, - @RequestParam(required = false, defaultValue = "0.0") shadow: Float, - @RequestParam(required = false, defaultValue = "1.0") highlight: Float, - @RequestParam(required = false, defaultValue = "0.5") midtone: Float, - @RequestParam(required = false, defaultValue = "false") mirrorHorizontal: Boolean, - @RequestParam(required = false, defaultValue = "false") mirrorVertical: Boolean, - @RequestParam(required = false, defaultValue = "false") invert: Boolean, - @RequestParam(required = false, defaultValue = "false") scnrEnabled: Boolean, - @RequestParam(required = false, defaultValue = "GREEN") scnrChannel: ImageChannel, - @RequestParam(required = false, defaultValue = "0.5") scnrAmount: Float, - @RequestParam(required = false, defaultValue = "AVERAGE_NEUTRAL") scnrProtectionMode: ProtectionMethod, + @RequestBody transformation: ImageTransformation, output: HttpServletResponse, - ) = imageService.openImage( - path, camera, - debayer, calibrate, force, autoStretch, shadow, highlight, midtone, - mirrorHorizontal, mirrorVertical, invert, - scnrEnabled, scnrChannel, scnrAmount, scnrProtectionMode, - output, - ) + ) = imageService.openImage(path, camera, transformation, output) @DeleteMapping fun closeImage(@RequestParam path: Path) { @@ -53,8 +32,12 @@ class ImageController( } @PutMapping("save-as") - fun saveImageAs(@RequestParam inputPath: Path, @RequestParam outputPath: Path) { - imageService.saveImageAs(inputPath, outputPath) + fun saveImageAs( + @RequestParam path: Path, + @DeviceOrEntityParam(required = false) camera: Camera?, + @RequestBody save: SaveImage + ) { + imageService.saveImageAs(path, save, camera) } @GetMapping("annotations") diff --git a/api/src/main/kotlin/nebulosa/api/image/ImageExtension.kt b/api/src/main/kotlin/nebulosa/api/image/ImageExtension.kt new file mode 100644 index 000000000..ba28ce766 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/image/ImageExtension.kt @@ -0,0 +1,8 @@ +package nebulosa.api.image + +enum class ImageExtension { + FITS, + XISF, + PNG, + JPG, +} diff --git a/api/src/main/kotlin/nebulosa/api/image/ImageInfo.kt b/api/src/main/kotlin/nebulosa/api/image/ImageInfo.kt index 25d2d8807..f9afc085d 100644 --- a/api/src/main/kotlin/nebulosa/api/image/ImageInfo.kt +++ b/api/src/main/kotlin/nebulosa/api/image/ImageInfo.kt @@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.databind.annotation.JsonSerialize import nebulosa.api.beans.converters.angle.DeclinationSerializer import nebulosa.api.beans.converters.angle.RightAscensionSerializer +import nebulosa.fits.Bitpix import nebulosa.image.algorithms.computation.Statistics import nebulosa.indi.device.camera.Camera import java.nio.file.Path @@ -16,6 +17,7 @@ data class ImageInfo( @field:JsonSerialize(using = DeclinationSerializer::class) val declination: Double? = null, val solved: ImageSolved? = null, val headers: List = emptyList(), + val bitpix: Bitpix = Bitpix.BYTE, val camera: Camera? = null, @JsonIgnoreProperties("histogram") val statistics: Statistics.Data? = null, ) diff --git a/api/src/main/kotlin/nebulosa/api/image/ImageService.kt b/api/src/main/kotlin/nebulosa/api/image/ImageService.kt index 216841c87..9038eeb82 100644 --- a/api/src/main/kotlin/nebulosa/api/image/ImageService.kt +++ b/api/src/main/kotlin/nebulosa/api/image/ImageService.kt @@ -12,7 +12,7 @@ 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.ImageModifier import nebulosa.indi.device.camera.Camera import nebulosa.log.debug import nebulosa.log.loggerFor @@ -40,7 +40,6 @@ import java.time.LocalDateTime import java.util.* import java.util.concurrent.CompletableFuture import javax.imageio.ImageIO -import kotlin.io.path.extension import kotlin.io.path.outputStream @Service @@ -56,6 +55,18 @@ class ImageService( private val starDetector: StarDetector, ) { + private enum class ImageOperation { + OPEN, + SAVE, + } + + private data class TransformedImage( + @JvmField val image: Image, + @JvmField val statistics: Statistics.Data? = null, + @JvmField val strectchParams: ScreenTransformFunction.Parameters? = null, + @JvmField val instrument: Camera? = null, + ) + val fovCameras: ByteArray by lazy { URI.create("https://github.com/tiagohm/nebulosa.data/raw/main/astrobin/cameras.json") .toURL().openConnection().getInputStream().readAllBytes() @@ -68,69 +79,82 @@ class ImageService( @Synchronized fun openImage( - path: Path, camera: Camera?, debayer: Boolean = true, calibrate: Boolean = false, force: Boolean = false, - autoStretch: Boolean = false, shadow: Float = 0f, highlight: Float = 1f, midtone: Float = 0.5f, - mirrorHorizontal: Boolean = false, mirrorVertical: Boolean = false, invert: Boolean = false, - scnrEnabled: Boolean = false, scnrChannel: ImageChannel = ImageChannel.GREEN, scnrAmount: Float = 0.5f, - scnrProtectionMode: ProtectionMethod = ProtectionMethod.AVERAGE_NEUTRAL, + path: Path, camera: Camera?, transformation: ImageTransformation, output: HttpServletResponse, ) { - val image = imageBucket.open(path, debayer, force = force) + val image = imageBucket.open(path, transformation.debayer, force = transformation.force) + val (transformedImage, statistics, stretchParams, instrument) = image.transform(true, transformation, ImageOperation.OPEN, camera) + + val info = ImageInfo( + path, + transformedImage.width, transformedImage.height, transformedImage.mono, + stretchParams!!.shadow, stretchParams.highlight, stretchParams.midtone, + transformedImage.header.rightAscension.takeIf { it.isFinite() }, + transformedImage.header.declination.takeIf { it.isFinite() }, + imageBucket[path]?.second?.let(::ImageSolved), + transformedImage.header.mapNotNull { if (it.isCommentStyle) null else ImageHeaderItem(it.key, it.value) }, + transformedImage.header.bitpix, instrument, statistics, + ) + + output.addHeader(IMAGE_INFO_HEADER, objectMapper.writeValueAsString(info)) + output.contentType = "image/png" + + ImageIO.write(transformedImage, "PNG", output.outputStream) + } + + private fun Image.transform( + enabled: Boolean, transformation: ImageTransformation, + operation: ImageOperation, camera: Camera? = null + ): TransformedImage { + val instrument = camera ?: header.instrument?.let(connectionService::camera) + val (autoStretch, shadow, highlight, midtone) = transformation.stretch + val scnrEnabled = transformation.scnr.channel != null val manualStretch = shadow != 0f || highlight != 1f || midtone != 0.5f - var stretchParams = ScreenTransformFunction.Parameters(midtone, shadow, highlight) - val shouldBeTransformed = autoStretch || manualStretch - || mirrorHorizontal || mirrorVertical || invert - || scnrEnabled + val shouldBeTransformed = enabled && (autoStretch || manualStretch + || transformation.mirrorHorizontal || transformation.mirrorVertical || transformation.invert + || scnrEnabled) - var transformedImage = if (shouldBeTransformed) image.clone() else image - val instrument = camera?.name ?: image.header.instrument + var transformedImage = if (shouldBeTransformed) clone() else this - if (calibrate && !instrument.isNullOrBlank()) { - transformedImage = calibrationFrameService.calibrate(instrument, transformedImage, transformedImage === image) + if (enabled && transformation.calibrate && instrument != null) { + transformedImage = calibrationFrameService.calibrate(instrument.name, transformedImage, transformedImage === this) } - if (mirrorHorizontal) { + if (enabled && transformation.mirrorHorizontal) { transformedImage = HorizontalFlip.transform(transformedImage) } - if (mirrorVertical) { + if (enabled && transformation.mirrorVertical) { transformedImage = VerticalFlip.transform(transformedImage) } - if (scnrEnabled) { - transformedImage = SubtractiveChromaticNoiseReduction(scnrChannel, scnrAmount, scnrProtectionMode).transform(transformedImage) + if (enabled && scnrEnabled) { + val (channel, amount, method) = transformation.scnr + transformedImage = SubtractiveChromaticNoiseReduction(channel!!, amount, method) + .transform(transformedImage) } - val statistics = transformedImage.compute(Statistics.GRAY) + val statistics = if (operation == ImageOperation.OPEN) transformedImage.compute(Statistics.GRAY) + else null - if (autoStretch) { - stretchParams = AutoScreenTransformFunction.compute(transformedImage) - transformedImage = ScreenTransformFunction(stretchParams).transform(transformedImage) - } else if (manualStretch) { - transformedImage = ScreenTransformFunction(stretchParams).transform(transformedImage) + var stretchParams = ScreenTransformFunction.Parameters.DEFAULT + + if (enabled) { + if (autoStretch) { + stretchParams = AutoScreenTransformFunction.compute(transformedImage) + transformedImage = ScreenTransformFunction(stretchParams).transform(transformedImage) + } else if (manualStretch) { + stretchParams = ScreenTransformFunction.Parameters(midtone, shadow, highlight) + transformedImage = ScreenTransformFunction(stretchParams).transform(transformedImage) + } } - if (invert) { + if (enabled && transformation.invert) { transformedImage = Invert.transform(transformedImage) } - val info = ImageInfo( - path, - transformedImage.width, transformedImage.height, transformedImage.mono, - stretchParams.shadow, stretchParams.highlight, stretchParams.midtone, - transformedImage.header.rightAscension.takeIf { it.isFinite() }, - transformedImage.header.declination.takeIf { it.isFinite() }, - imageBucket[path]?.second?.let(::ImageSolved), - transformedImage.header.mapNotNull { if (it.isCommentStyle) null else ImageHeaderItem(it.key, it.value) }, - instrument?.let(connectionService::camera), - statistics, - ) - - output.addHeader("X-Image-Info", objectMapper.writeValueAsString(info)) - output.contentType = "image/png" - - ImageIO.write(transformedImage, "PNG", output.outputStream) + return TransformedImage(transformedImage, statistics, stretchParams, instrument) } @Synchronized @@ -244,18 +268,20 @@ class ImageService( return annotations } - fun saveImageAs(inputPath: Path, outputPath: Path) { - if (inputPath != outputPath) { - val image = imageBucket[inputPath]?.first - ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Image not found") - - when (outputPath.extension.uppercase()) { - "PNG" -> outputPath.outputStream().use { ImageIO.write(image, "PNG", it) } - "JPG", "JPEG" -> outputPath.outputStream().use { ImageIO.write(image, "JPEG", it) } - "FIT", "FITS" -> outputPath.sink().use { image.writeTo(it, FitsFormat) } - "XISF" -> outputPath.sink().use { image.writeTo(it, XisfFormat) } - else -> throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid format") - } + fun saveImageAs(inputPath: Path, save: SaveImage, camera: Camera?) { + val (image) = imageBucket[inputPath]?.first?.transform(save.shouldBeTransformed, save.transformation, ImageOperation.SAVE) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Image not found") + + require(save.path != null) + + val modifier = ImageModifier + .bitpix(save.bitpix) + + when (save.format) { + ImageExtension.FITS -> save.path.sink().use { image.writeTo(it, FitsFormat, modifier) } + ImageExtension.XISF -> save.path.sink().use { image.writeTo(it, XisfFormat, modifier) } + ImageExtension.PNG -> save.path.outputStream().use { ImageIO.write(image, "PNG", it) } + ImageExtension.JPG -> save.path.outputStream().use { ImageIO.write(image, "JPEG", it) } } } @@ -320,6 +346,7 @@ class ImageService( @JvmStatic private val LOG = loggerFor() + private const val IMAGE_INFO_HEADER = "X-Image-Info" private const val COORDINATE_INTERPOLATION_DELTA = 24 } } diff --git a/api/src/main/kotlin/nebulosa/api/image/ImageTransformation.kt b/api/src/main/kotlin/nebulosa/api/image/ImageTransformation.kt new file mode 100644 index 000000000..f534a87d6 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/image/ImageTransformation.kt @@ -0,0 +1,46 @@ +package nebulosa.api.image + +import nebulosa.image.algorithms.transformation.ProtectionMethod +import nebulosa.image.format.ImageChannel + +data class ImageTransformation( + val force: Boolean = false, + val calibrate: Boolean = false, + val debayer: Boolean = true, + val stretch: Stretch = Stretch.EMPTY, + val mirrorHorizontal: Boolean = false, + val mirrorVertical: Boolean = false, + val invert: Boolean = false, + val scnr: SCNR = SCNR.EMPTY, +) { + + data class SCNR( + val channel: ImageChannel? = ImageChannel.GREEN, + val amount: Float = 0.5f, + val method: ProtectionMethod = ProtectionMethod.AVERAGE_NEUTRAL, + ) { + + companion object { + + @JvmStatic val EMPTY = SCNR() + } + } + + data class Stretch( + val auto: Boolean = false, + val shadow: Float = 0f, + val highlight: Float = 0.5f, + val midtone: Float = 1f, + ) { + + companion object { + + @JvmStatic val EMPTY = Stretch() + } + } + + companion object { + + @JvmStatic val EMPTY = ImageTransformation() + } +} diff --git a/api/src/main/kotlin/nebulosa/api/image/SaveImage.kt b/api/src/main/kotlin/nebulosa/api/image/SaveImage.kt new file mode 100644 index 000000000..1bfa717f4 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/image/SaveImage.kt @@ -0,0 +1,12 @@ +package nebulosa.api.image + +import nebulosa.fits.Bitpix +import java.nio.file.Path + +data class SaveImage( + val format: ImageExtension = ImageExtension.FITS, + val bitpix: Bitpix = Bitpix.BYTE, + val shouldBeTransformed: Boolean = true, + val transformation: ImageTransformation = ImageTransformation.EMPTY, + val path: Path? = null, +) diff --git a/desktop/app/main.ts b/desktop/app/main.ts index 04d3ac859..7ee09f962 100644 --- a/desktop/app/main.ts +++ b/desktop/app/main.ts @@ -250,6 +250,8 @@ function createWindow(options: OpenWindow, parent?: BrowserWindow) { browserWindows.set(id, window) + console.info('window created: ', id, window.id) + return window } diff --git a/desktop/src/app/alignment/alignment.component.ts b/desktop/src/app/alignment/alignment.component.ts index d11200c64..8c5937776 100644 --- a/desktop/src/app/alignment/alignment.component.ts +++ b/desktop/src/app/alignment/alignment.component.ts @@ -8,7 +8,7 @@ import { Angle } from '../../shared/types/atlas.types' import { Camera, EMPTY_CAMERA, EMPTY_CAMERA_START_CAPTURE, ExposureTimeUnit } from '../../shared/types/camera.types' import { EMPTY_GUIDE_OUTPUT, GuideDirection, GuideOutput } from '../../shared/types/guider.types' import { EMPTY_MOUNT, Mount } from '../../shared/types/mount.types' -import { DEFAULT_SOLVER_TYPES, EMPTY_PLATE_SOLVER_OPTIONS } from '../../shared/types/settings.types' +import { DEFAULT_SOLVER_TYPES, EMPTY_PLATE_SOLVER_PREFERENCE } from '../../shared/types/settings.types' import { deviceComparator } from '../../shared/utils/comparators' import { AppComponent } from '../app.component' import { CameraComponent } from '../camera/camera.component' @@ -41,7 +41,7 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { readonly tppaRequest: TPPAStart = { capture: structuredClone(EMPTY_CAMERA_START_CAPTURE), - plateSolver: structuredClone(EMPTY_PLATE_SOLVER_OPTIONS), + plateSolver: structuredClone(EMPTY_PLATE_SOLVER_PREFERENCE), startFromCurrentPosition: true, eastDirection: true, compensateRefraction: true, @@ -295,7 +295,7 @@ export class AlignmentComponent implements AfterViewInit, OnDestroy { } plateSolverChanged() { - this.tppaRequest.plateSolver = this.preference.plateSolverOptions(this.tppaRequest.plateSolver.type).get() + this.tppaRequest.plateSolver = this.preference.plateSolverPreference(this.tppaRequest.plateSolver.type).get() this.savePreference() } diff --git a/desktop/src/app/calibration/calibration.component.ts b/desktop/src/app/calibration/calibration.component.ts index 18ed705b2..6c77001db 100644 --- a/desktop/src/app/calibration/calibration.component.ts +++ b/desktop/src/app/calibration/calibration.component.ts @@ -1,11 +1,11 @@ import { AfterViewInit, Component, HostListener, NgZone, OnDestroy } from '@angular/core' import { ActivatedRoute } from '@angular/router' -import path from 'path' +import { dirname } from 'path' import { CheckboxChangeEvent } from 'primeng/checkbox' import { ApiService } from '../../shared/services/api.service' import { BrowserWindowService } from '../../shared/services/browser-window.service' import { ElectronService } from '../../shared/services/electron.service' -import { LocalStorageService } from '../../shared/services/local-storage.service' +import { PreferenceService } from '../../shared/services/preference.service' import { CalibrationFrame, CalibrationFrameGroup } from '../../shared/types/calibration.types' import { Camera } from '../../shared/types/camera.types' import { AppComponent } from '../app.component' @@ -35,7 +35,7 @@ export class CalibrationComponent implements AfterViewInit, OnDestroy { electron: ElectronService, private browserWindow: BrowserWindowService, private route: ActivatedRoute, - private storage: LocalStorageService, + private preference: PreferenceService, ngZone: NgZone, ) { app.title = 'Calibration' @@ -44,12 +44,13 @@ export class CalibrationComponent implements AfterViewInit, OnDestroy { icon: 'mdi mdi-image-plus', tooltip: 'Add file', command: async () => { - const defaultPath = this.storage.get(CALIBRATION_DIR_KEY, '') - const filePath = await electron.openFits({ defaultPath }) + const preference = this.preference.calibrationPreference.get() + const path = await electron.openImage({ defaultPath: preference.openPath }) - if (filePath) { - this.storage.set(CALIBRATION_DIR_KEY, path.dirname(filePath)) - this.upload(filePath) + if (path) { + preference.openPath = dirname(path) + this.preference.calibrationPreference.set(preference) + this.upload(path) } }, }) @@ -58,12 +59,13 @@ export class CalibrationComponent implements AfterViewInit, OnDestroy { icon: 'mdi mdi-folder-plus', tooltip: 'Add folder', command: async () => { - const defaultPath = this.storage.get(CALIBRATION_DIR_KEY, '') - const dirPath = await electron.openDirectory({ defaultPath }) + const preference = this.preference.calibrationPreference.get() + const path = await electron.openDirectory({ defaultPath: preference.openPath }) - if (dirPath) { - this.storage.set(CALIBRATION_DIR_KEY, dirPath) - this.upload(dirPath) + if (path) { + preference.openPath = path + this.preference.calibrationPreference.set(preference) + this.upload(path) } }, }) diff --git a/desktop/src/app/home/home.component.ts b/desktop/src/app/home/home.component.ts index 33239f4ad..464c91bd7 100644 --- a/desktop/src/app/home/home.component.ts +++ b/desktop/src/app/home/home.component.ts @@ -1,5 +1,5 @@ import { AfterContentInit, Component, HostListener, NgZone, OnDestroy, ViewChild } from '@angular/core' -import path from 'path' +import { dirname } from 'path' import { MenuItem } from 'primeng/api' import { DeviceListMenuComponent } from '../../shared/components/device-list-menu/device-list-menu.component' import { DialogMenuComponent } from '../../shared/components/dialog-menu/dialog-menu.component' @@ -340,12 +340,13 @@ export class HomeComponent implements AfterContentInit, OnDestroy { private async openImage(force: boolean = false) { if (force || this.cameras.length === 0) { - const defaultPath = this.preference.homeImageDirectory.get() - const filePath = await this.electron.openFits({ defaultPath }) + const preference = this.preference.homePreference.get() + const path = await this.electron.openImage({ defaultPath: preference.imagePath }) - if (filePath) { - this.preference.homeImageDirectory.set(path.dirname(filePath)) - this.browserWindow.openImage({ path: filePath, source: 'PATH' }) + if (path) { + preference.imagePath = dirname(path) + this.preference.homePreference.set(preference) + this.browserWindow.openImage({ path, source: 'PATH' }) } } else { const camera = await this.imageMenu.show(this.cameras) diff --git a/desktop/src/app/image/image.component.html b/desktop/src/app/image/image.component.html index e2fb0d440..00676de6a 100644 --- a/desktop/src/app/image/image.component.html +++ b/desktop/src/app/image/image.component.html @@ -10,7 +10,7 @@ - @@ -20,9 +20,9 @@ - - + {{ s.hfd.toFixed(1) }} @@ -31,7 +31,7 @@ - @for (item of fovs; track $index) { + @for (item of fov.fovs; track $index) { @if (item.enabled && item.computed) { @@ -40,7 +40,7 @@
- X: {{ roiX }} Y: {{ roiY }} W: {{ roiWidth }} H: {{ roiHeight }} + X: {{ imageROI.x }} Y: {{ imageROI.y }} W: {{ imageROI.width }} H: {{ imageROI.height }}
@@ -146,27 +146,27 @@ -
- + - +
- +
- +
@@ -174,8 +174,8 @@
- +
@@ -184,60 +184,60 @@
- +
- +
- +
- +
+ [value]="(solver.solved.width.toFixed(2)) + ' x ' + (solver.solved.height.toFixed(2))" />
- +
- - - - + +
- + -
@@ -245,17 +245,18 @@
+ [(ngModel)]="stretchShadow" locale="en" /> + [(ngModel)]="stretchHighlight" locale="en" />
- +
@@ -276,16 +277,17 @@ -
- +
- +
{{ item | enum }} @@ -302,9 +304,9 @@
- + [(ngModel)]="scnr.amount" locale="en" [allowEmpty]="false" />
@@ -314,10 +316,10 @@
-
- + Name @@ -406,7 +408,7 @@
-
@@ -483,15 +485,15 @@
-->
- - -
-
- @for (item of fovs; track $index) { +
+ @for (item of fov.fovs; track $index) {
@@ -522,11 +524,11 @@
-
- @@ -540,15 +542,15 @@
- +
-
- @@ -561,7 +563,38 @@
- + + +
+ + +
+
+ + + + +
+
+ +
+
+ + + + +
+
+ Transformed + +
+
+ +
diff --git a/desktop/src/app/image/image.component.ts b/desktop/src/app/image/image.component.ts index 3b3c4d9e4..0b7c76d04 100644 --- a/desktop/src/app/image/image.component.ts +++ b/desktop/src/app/image/image.component.ts @@ -1,10 +1,10 @@ -import { AfterViewInit, Component, ElementRef, HostListener, NgZone, OnDestroy, ViewChild } from '@angular/core' +import { AfterViewInit, Component, ElementRef, HostListener, NgZone, OnDestroy, ViewChild, computed, model } from '@angular/core' import { ActivatedRoute } from '@angular/router' import { Interactable } from '@interactjs/types/index' import hotkeys from 'hotkeys-js' import interact from 'interactjs' import createPanZoom, { PanZoom } from 'panzoom' -import * as path from 'path' +import { basename, dirname, extname } from 'path' import { MenuItem } from 'primeng/api' import { ContextMenu } from 'primeng/contextmenu' import { DeviceListMenuComponent } from '../../shared/components/device-list-menu/device-list-menu.component' @@ -17,7 +17,7 @@ import { PreferenceService } from '../../shared/services/preference.service' import { PrimeService } from '../../shared/services/prime.service' import { CheckableMenuItem, ToggleableMenuItem } from '../../shared/types/app.types' import { Angle, AstronomicalObject, DeepSkyObject, EquatorialCoordinateJ2000, Star } from '../../shared/types/atlas.types' -import { DEFAULT_FOV, DetectedStar, EMPTY_IMAGE_SOLVED, FITSHeaderItem, FOV, FOVCamera, FOVTelescope, ImageAnnotation, ImageChannel, ImageData, ImageInfo, ImagePreference, ImageSolved, ImageStatisticsBitOption, SCNRProtectionMethod, SCNR_PROTECTION_METHODS } from '../../shared/types/image.types' +import { DEFAULT_FOV, EMPTY_IMAGE_SOLVED, FOV, IMAGE_STATISTICS_BIT_OPTIONS, ImageAnnotation, ImageChannel, ImageData, ImageDetectStars, ImageFITSHeaders, ImageFOV, ImageInfo, ImagePreference, ImageROI, ImageSCNR, ImageSave, ImageSolved, ImageSolver, ImageStatisticsBitOption, ImageStretch, ImageTransformation, SCNR_PROTECTION_METHODS } from '../../shared/types/image.types' import { Mount } from '../../shared/types/mount.types' import { DEFAULT_SOLVER_TYPES } from '../../shared/types/settings.types' import { CoordinateInterpolator, InterpolatedCoordinate } from '../../shared/utils/coordinate-interpolation' @@ -45,40 +45,63 @@ export class ImageComponent implements AfterViewInit, OnDestroy { @ViewChild('histogram') private readonly histogram!: HistogramComponent - debayer = true - calibrate = true - mirrorHorizontal = false - mirrorVertical = false - invert = false + imageInfo?: ImageInfo + private imageURL!: string + imageData: ImageData = {} + + readonly scnrChannels: { name: string, value?: ImageChannel }[] = [ + { name: 'None', value: undefined }, + { name: 'Red', value: 'RED' }, + { name: 'Green', value: 'GREEN' }, + { name: 'Blue', value: 'BLUE' }, + ] + readonly scnrMethods = Array.from(SCNR_PROTECTION_METHODS) + readonly scnr: ImageSCNR = { + showDialog: false, + amount: 0.5, + method: 'AVERAGE_NEUTRAL', + } + + readonly stretch: ImageStretch = { + showDialog: false, + auto: true, + shadow: 0, + highlight: 1, + midtone: 0.5 + } - readonly scnrChannelOptions: ImageChannel[] = ['NONE', 'RED', 'GREEN', 'BLUE'] - readonly scnrProtectionMethodOptions: SCNRProtectionMethod[] = [...SCNR_PROTECTION_METHODS] + readonly stretchShadow = model(0) + readonly stretchHighlight = model(65536) + readonly stretchMidtone = model(32768) + readonly stretchShadowAndHighlight = computed(() => [this.stretchShadow(), this.stretchHighlight()]) - showSCNRDialog = false - scnrChannel: ImageChannel = 'NONE' - scnrAmount = 0.5 - scnrProtectionMethod: SCNRProtectionMethod = 'AVERAGE_NEUTRAL' + readonly transformation: ImageTransformation = { + force: false, + calibrate: true, + debayer: true, + stretch: this.stretch, + mirrorHorizontal: false, + mirrorVertical: false, + invert: false, + scnr: this.scnr + } showAnnotationDialog = false annotateWithStarsAndDSOs = true annotateWithMinorPlanets = false annotateWithMinorPlanetsMagLimit = 12.0 - autoStretched = true - showStretchingDialog = false - stretchShadowHighlight = [0, 65536] - stretchMidtone = 32768 - - showSolverDialog = false - solving = false - solved = false - solverBlind = true - solverCenterRA: Angle = '' - solverCenterDEC: Angle = '' - solverRadius = 4 - readonly imageSolved = structuredClone(EMPTY_IMAGE_SOLVED) - readonly solverTypes = Array.from(DEFAULT_SOLVER_TYPES) - solverType = this.solverTypes[0] + readonly solver: ImageSolver = { + showDialog: false, + solving: false, + blind: true, + centerRA: '', + centerDEC: '', + radius: 4, + solved: structuredClone(EMPTY_IMAGE_SOLVED), + types: Array.from(DEFAULT_SOLVER_TYPES), + type: 'ASTAP' + } crossHair = false annotations: ImageAnnotation[] = [] @@ -87,37 +110,30 @@ export class ImageComponent implements AfterViewInit, OnDestroy { annotationInfo?: AstronomicalObject & Partial annotationIsVisible = false - detectedStars: DetectedStar[] = [] - detectedStarsIsVisible = false + readonly detectedStars: ImageDetectStars = { + visible: false, + stars: [] + } - showFITSHeadersDialog = false - fitsHeaders: FITSHeaderItem[] = [] + readonly fitsHeaders: ImageFITSHeaders = { + showDialog: false, + headers: [] + } showStatisticsDialog = false - readonly statisticsBitOptions: 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 }, - ] - + readonly statisticsBitOptions: ImageStatisticsBitOption[] = IMAGE_STATISTICS_BIT_OPTIONS statisticsBitLength = this.statisticsBitOptions[0] - imageInfo?: ImageInfo - showFOVDialog = false - readonly fov = structuredClone(DEFAULT_FOV) - fovs: FOV[] = [] - editedFOV?: FOV - showFOVCamerasDialog = false - fovCameras: FOVCamera[] = [] - fovCamera?: FOVCamera - showFOVTelescopesDialog = false - fovTelescopes: FOVTelescope[] = [] - fovTelescope?: FOVTelescope + readonly fov: ImageFOV = { + ...structuredClone(DEFAULT_FOV), + showDialog: false, + fovs: [], + showCameraDialog: false, + cameras: [], + showTelescopeDialog: false, + telescopes: [], + } get canAddFOV() { return this.fov.aperture && this.fov.focalLength && @@ -127,23 +143,45 @@ export class ImageComponent implements AfterViewInit, OnDestroy { } private panZoom?: PanZoom - private imageURL!: string private imageMouseX = 0 private imageMouseY = 0 - private imageData: ImageData = {} - roiX = 0 - roiY = 0 - roiWidth = 128 - roiHeight = 128 roiInteractable?: Interactable + readonly imageROI: ImageROI = { + x: 0, + y: 0, + width: 0, + height: 0 + } + + readonly saveAs: ImageSave = { + showDialog: false, + format: 'FITS', + bitpix: 'BYTE', + path: '', + shouldBeTransformed: true, + transformation: this.transformation + } private readonly saveAsMenuItem: MenuItem = { label: 'Save as...', icon: 'mdi mdi-content-save', command: async () => { - const path = await this.electron.saveFits() - if (path) this.api.saveImageAs(this.imageData.path!, path) + const preference = this.preference.imagePreference.get() + + const path = await this.electron.saveImage({ defaultPath: preference.savePath }) + + if (path) { + const extension = extname(path).toLowerCase() + this.saveAs.format = extension === '.xisf' ? 'XISF' : + extension === '.png' ? 'PNG' : extension === '.jpg' ? 'JPG' : 'FITS' + this.saveAs.bitpix = this.imageInfo?.bitpix || 'BYTE' + this.saveAs.path = path + this.saveAs.showDialog = true + + preference.savePath = dirname(path) + this.preference.imagePreference.set(preference) + } }, } @@ -151,7 +189,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { label: 'Plate Solve', icon: 'mdi mdi-sigma', command: () => { - this.showSolverDialog = true + this.solver.showDialog = true }, } @@ -159,7 +197,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { label: 'Stretch', icon: 'mdi mdi-chart-histogram', command: () => { - this.showStretchingDialog = true + this.stretch.showDialog = true }, } @@ -178,7 +216,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { icon: 'mdi mdi-palette', disabled: true, command: () => { - this.showSCNRDialog = true + this.scnr.showDialog = true }, } @@ -187,8 +225,8 @@ export class ImageComponent implements AfterViewInit, OnDestroy { icon: 'mdi mdi-flip-horizontal', checked: false, command: () => { - this.mirrorHorizontal = !this.mirrorHorizontal - this.horizontalMirrorMenuItem.checked = this.mirrorHorizontal + this.transformation.mirrorHorizontal = !this.transformation.mirrorHorizontal + this.horizontalMirrorMenuItem.checked = this.transformation.mirrorHorizontal this.loadImage() }, } @@ -198,8 +236,8 @@ export class ImageComponent implements AfterViewInit, OnDestroy { icon: 'mdi mdi-flip-vertical', checked: false, command: () => { - this.mirrorVertical = !this.mirrorVertical - this.verticalMirrorMenuItem.checked = this.mirrorVertical + this.transformation.mirrorVertical = !this.transformation.mirrorVertical + this.verticalMirrorMenuItem.checked = this.transformation.mirrorVertical this.loadImage() }, } @@ -218,8 +256,8 @@ export class ImageComponent implements AfterViewInit, OnDestroy { icon: 'mdi mdi-tools', checked: true, command: () => { - this.calibrate = !this.calibrate - this.calibrateMenuItem.checked = this.calibrate + this.transformation.calibrate = !this.transformation.calibrate + this.calibrateMenuItem.checked = this.transformation.calibrate this.loadImage() }, } @@ -237,7 +275,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { icon: 'mdi mdi-list-box', label: 'FITS Header', command: () => { - this.showFITSHeadersDialog = true + this.fitsHeaders.showDialog = true }, } @@ -296,14 +334,14 @@ export class ImageComponent implements AfterViewInit, OnDestroy { toggleable: false, toggled: false, command: async () => { - this.detectedStars = await this.api.detectStars(this.imageData.path!) - this.detectedStarsIsVisible = this.detectedStars.length > 0 - this.detectStarsMenuItem.toggleable = this.detectedStarsIsVisible - this.detectStarsMenuItem.toggled = this.detectedStarsIsVisible + this.detectedStars.stars = await this.api.detectStars(this.imageData.path!) + this.detectedStars.visible = this.detectedStars.stars.length > 0 + this.detectStarsMenuItem.toggleable = this.detectedStars.visible + this.detectStarsMenuItem.toggled = this.detectedStars.visible }, toggle: (event) => { event.originalEvent?.stopImmediatePropagation() - this.detectedStarsIsVisible = event.checked + this.detectedStars.visible = event.checked }, } @@ -351,10 +389,10 @@ export class ImageComponent implements AfterViewInit, OnDestroy { label: 'Field of View', icon: 'mdi mdi-camera-metering-spot', command: () => { - this.showFOVDialog = !this.showFOVDialog + this.fov.showDialog = !this.fov.showDialog - if (this.showFOVDialog) { - this.fovs.forEach(e => this.computeFOV(e)) + if (this.fov.showDialog) { + this.fov.fovs.forEach(e => this.computeFOV(e)) } }, } @@ -396,7 +434,8 @@ export class ImageComponent implements AfterViewInit, OnDestroy { private mouseCoordinateInterpolation?: CoordinateInterpolator get isMouseCoordinateVisible() { - return !!this.mouseCoordinate && !this.mirrorHorizontal && !this.mirrorVertical + return !!this.mouseCoordinate && !this.transformation.mirrorHorizontal + && !this.transformation.mirrorVertical } constructor( @@ -411,6 +450,18 @@ export class ImageComponent implements AfterViewInit, OnDestroy { ) { app.title = 'Image' + this.stretchShadow.subscribe(value => { + this.stretch.shadow = value / 65536 + }) + + this.stretchHighlight.subscribe(value => { + this.stretch.highlight = value / 65536 + }) + + this.stretchMidtone.subscribe(value => { + this.stretch.midtone = value / 65536 + }) + electron.on('CAMERA.CAPTURE_ELAPSED', async (event) => { if (event.state === 'EXPOSURE_FINISHED' && event.camera.id === this.imageData.camera?.id) { await this.closeImage(event.savePath !== this.imageData.path) @@ -483,10 +534,10 @@ export class ImageComponent implements AfterViewInit, OnDestroy { target.setAttribute('data-y', y) this.ngZone.run(() => { - this.roiX = Math.round(x) - this.roiY = Math.round(y) - this.roiWidth = Math.round(event.rect.width / scale) - this.roiHeight = Math.round(event.rect.height / scale) + this.imageROI.x = Math.round(x) + this.imageROI.y = Math.round(y) + this.imageROI.width = Math.round(event.rect.width / scale) + this.imageROI.height = Math.round(event.rect.height / scale) }) } @@ -504,8 +555,8 @@ export class ImageComponent implements AfterViewInit, OnDestroy { target.setAttribute('data-y', y) this.ngZone.run(() => { - this.roiX = Math.round(x) - this.roiY = Math.round(y) + this.imageROI.x = Math.round(x) + this.imageROI.y = Math.round(y) }) } @@ -535,11 +586,11 @@ export class ImageComponent implements AfterViewInit, OnDestroy { this.annotationIsVisible = false this.annotationMenuItem.toggleable = false - this.detectedStars = [] - this.detectedStarsIsVisible = false + this.detectedStars.stars = [] + this.detectedStars.visible = false this.detectStarsMenuItem.toggleable = false - Object.assign(this.imageSolved, EMPTY_IMAGE_SOLVED) + Object.assign(this.solver.solved, EMPTY_IMAGE_SOLVED) this.histogram?.update([]) } @@ -563,7 +614,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { } else if (this.imageData.camera) { this.app.subTitle = this.imageData.camera.name } else if (this.imageData.path) { - this.app.subTitle = path.basename(this.imageData.path) + this.app.subTitle = basename(this.imageData.path) } else { this.app.subTitle = '' } @@ -571,26 +622,25 @@ export class ImageComponent implements AfterViewInit, OnDestroy { private async loadImageFromPath(path: string) { const image = this.image.nativeElement - const scnrEnabled = this.scnrChannel !== 'NONE' - const { info, blob } = await this.api.openImage(path, this.imageData.camera, this.calibrate, this.debayer, !!this.imageData.camera, this.autoStretched, - this.stretchShadowHighlight[0] / 65536, this.stretchShadowHighlight[1] / 65536, this.stretchMidtone / 65536, - this.mirrorHorizontal, this.mirrorVertical, this.invert, scnrEnabled, scnrEnabled ? this.scnrChannel : 'GREEN', this.scnrAmount, this.scnrProtectionMethod) + + const { info, blob } = await this.api.openImage(path, this.transformation, this.imageData.camera) this.imageInfo = info this.scnrMenuItem.disabled = info.mono - if (info.rightAscension) this.solverCenterRA = info.rightAscension - if (info.declination) this.solverCenterDEC = info.declination - this.solverBlind = !this.solverCenterRA || !this.solverCenterDEC + if (info.rightAscension) this.solver.centerRA = info.rightAscension + if (info.declination) this.solver.centerDEC = info.declination + this.solver.blind = !this.solver.centerRA || !this.solver.centerDEC - if (this.autoStretched) { - this.stretchShadowHighlight = [Math.trunc(info.stretchShadow * 65536), Math.trunc(info.stretchHighlight * 65536)] - this.stretchMidtone = Math.trunc(info.stretchMidtone * 65536) + if (this.stretch.auto) { + this.stretchShadow.set(Math.trunc(info.stretchShadow * 65536)) + this.stretchHighlight.set(Math.trunc(info.stretchHighlight * 65536)) + this.stretchMidtone.set(Math.trunc(info.stretchMidtone * 65536)) } this.updateImageSolved(info.solved) - this.fitsHeaders = info.headers + this.fitsHeaders.headers = info.headers if (this.imageURL) window.URL.revokeObjectURL(this.imageURL) this.imageURL = window.URL.createObjectURL(blob) @@ -623,6 +673,11 @@ export class ImageComponent implements AfterViewInit, OnDestroy { } } + async saveImageAs() { + await this.api.saveImageAs(this.imageData!.path!, this.saveAs, this.imageData.camera) + this.saveAs.showDialog = false + } + async annotateImage() { try { this.annotating = true @@ -643,26 +698,27 @@ export class ImageComponent implements AfterViewInit, OnDestroy { } private disableAutoStretch() { - this.autoStretched = false + this.stretch.auto = false this.autoStretchMenuItem.checked = false } private disableCalibrate(canEnable: boolean = true) { - this.calibrate = false + this.transformation.calibrate = false this.calibrateMenuItem.checked = false this.calibrateMenuItem.disabled = !canEnable } autoStretch() { - this.autoStretched = true + this.stretch.auto = true this.autoStretchMenuItem.checked = true this.loadImage() } resetStretch(load: boolean = true) { - this.stretchShadowHighlight = [0, 65536] - this.stretchMidtone = 32768 + this.stretchShadow.set(0) + this.stretchHighlight.set(65536) + this.stretchMidtone.set(32768) if (load) { this.stretchImage() @@ -670,10 +726,10 @@ export class ImageComponent implements AfterViewInit, OnDestroy { } toggleStretch() { - this.autoStretched = !this.autoStretched - this.autoStretchMenuItem.checked = this.autoStretched + this.stretch.auto = !this.stretch.auto + this.autoStretchMenuItem.checked = this.stretch.auto - if (!this.autoStretched) { + if (!this.stretch.auto) { this.resetStretch() } else { this.loadImage() @@ -686,8 +742,8 @@ export class ImageComponent implements AfterViewInit, OnDestroy { } invertImage() { - this.invert = !this.invert - this.invertMenuItem.checked = this.invert + this.transformation.invert = !this.transformation.invert + this.invertMenuItem.checked = this.transformation.invert this.loadImage() } @@ -733,33 +789,32 @@ export class ImageComponent implements AfterViewInit, OnDestroy { } async solveImage() { - this.solving = true + this.solver.solving = true try { - const options = this.preference.plateSolverOptions(this.solverType).get() - const solved = await this.api.solveImage(options, this.imageData.path!, this.solverBlind, - this.solverCenterRA, this.solverCenterDEC, this.solverRadius) + const solver = this.preference.plateSolverPreference(this.solver.type).get() + const solved = await this.api.solveImage(solver, this.imageData.path!, this.solver.blind, + this.solver.centerRA, this.solver.centerDEC, this.solver.radius) this.savePreference() this.updateImageSolved(solved) } catch { this.updateImageSolved(this.imageInfo?.solved) } finally { - this.solving = false + this.solver.solving = false this.retrieveCoordinateInterpolation() } } private updateImageSolved(solved?: ImageSolved) { - this.solved = !!solved - Object.assign(this.imageSolved, solved ?? EMPTY_IMAGE_SOLVED) - this.annotationMenuItem.disabled = !this.solved - this.fovMenuItem.disabled = !this.solved - this.pointMountHereMenuItem.disabled = !this.solved - this.frameAtThisCoordinateMenuItem.disabled = !this.solved + Object.assign(this.solver.solved, solved ?? EMPTY_IMAGE_SOLVED) + this.annotationMenuItem.disabled = !this.solver.solved.solved + this.fovMenuItem.disabled = !this.solver.solved.solved + this.pointMountHereMenuItem.disabled = !this.solver.solved.solved + this.frameAtThisCoordinateMenuItem.disabled = !this.solver.solved.solved - if (solved) this.fovs.forEach(e => this.computeFOV(e)) - else this.fovs.forEach(e => e.computed = undefined) + if (solved) this.fov.fovs.forEach(e => this.computeFOV(e)) + else this.fov.fovs.forEach(e => e.computed = undefined) } mountSync(coordinate: EquatorialCoordinateJ2000) { @@ -781,7 +836,7 @@ export class ImageComponent implements AfterViewInit, OnDestroy { } frame(coordinate: EquatorialCoordinateJ2000) { - this.browserWindow.openFraming({ data: { rightAscension: coordinate.rightAscensionJ2000, declination: coordinate.declinationJ2000, fov: this.imageSolved!.width / 60, rotation: this.imageSolved!.orientation } }) + this.browserWindow.openFraming({ data: { rightAscension: coordinate.rightAscensionJ2000, declination: coordinate.declinationJ2000, fov: this.solver.solved!.width / 60, rotation: this.solver.solved!.orientation } }) } imageLoaded() { @@ -805,69 +860,69 @@ export class ImageComponent implements AfterViewInit, OnDestroy { } async showFOVCameras() { - if (!this.fovCameras.length) { - this.fovCameras = await this.api.fovCameras() + if (!this.fov.cameras.length) { + this.fov.cameras = await this.api.fovCameras() } - this.fovCamera = undefined - this.showFOVCamerasDialog = true + this.fov.camera = undefined + this.fov.showCameraDialog = true } async showFOVTelescopes() { - if (!this.fovTelescopes.length) { - this.fovTelescopes = await this.api.fovTelescopes() + if (!this.fov.telescopes.length) { + this.fov.telescopes = await this.api.fovTelescopes() } - this.fovTelescope = undefined - this.showFOVTelescopesDialog = true + this.fov.telescope = undefined + this.fov.showTelescopeDialog = true } chooseCamera() { - if (this.fovCamera) { - this.fov.cameraSize.width = this.fovCamera.width - this.fov.cameraSize.height = this.fovCamera.height - this.fov.pixelSize.width = this.fovCamera.pixelSize - this.fov.pixelSize.height = this.fovCamera.pixelSize - this.fovCamera = undefined - this.showFOVCamerasDialog = false + if (this.fov.camera) { + this.fov.cameraSize.width = this.fov.camera.width + this.fov.cameraSize.height = this.fov.camera.height + this.fov.pixelSize.width = this.fov.camera.pixelSize + this.fov.pixelSize.height = this.fov.camera.pixelSize + this.fov.camera = undefined + this.fov.showCameraDialog = false } } chooseTelescope() { - if (this.fovTelescope) { - this.fov.aperture = this.fovTelescope.aperture - this.fov.focalLength = this.fovTelescope.focalLength - this.fovTelescope = undefined - this.showFOVTelescopesDialog = false + if (this.fov.telescope) { + this.fov.aperture = this.fov.telescope.aperture + this.fov.focalLength = this.fov.telescope.focalLength + this.fov.telescope = undefined + this.fov.showTelescopeDialog = false } } addFOV() { if (this.computeFOV(this.fov)) { - this.fovs.push(structuredClone(this.fov)) - this.preference.imageFOVs.set(this.fovs) + this.fov.fovs.push(structuredClone(this.fov)) + this.preference.imageFOVs.set(this.fov.fovs) } } editFOV(fov: FOV) { Object.assign(this.fov, structuredClone(fov)) - this.editedFOV = fov + this.fov.edited = fov } cancelEditFOV() { - this.editedFOV = undefined + this.fov.edited = undefined } saveFOV() { - if (this.editedFOV && this.computeFOV(this.fov)) { - Object.assign(this.editedFOV, structuredClone(this.fov)) - this.preference.imageFOVs.set(this.fovs) - this.editedFOV = undefined + if (this.fov.edited && this.computeFOV(this.fov)) { + Object.assign(this.fov.edited, structuredClone(this.fov)) + this.preference.imageFOVs.set(this.fov.fovs) + this.fov.edited = undefined } } private computeFOV(fov: FOV) { - if (this.imageInfo && this.imageSolved.scale > 0) { + if (this.imageInfo && this.solver.solved.scale > 0) { const focalLength = fov.focalLength * (fov.barlowReducer || 1) const resolution = { @@ -878,8 +933,8 @@ export class ImageComponent implements AfterViewInit, OnDestroy { const svg = { x: this.imageInfo.width / 2, y: this.imageInfo.height / 2, - width: fov.cameraSize.width * (resolution.width / this.imageSolved.scale), - height: fov.cameraSize.height * (resolution.height / this.imageSolved.scale), + width: fov.cameraSize.width * (resolution.width / this.solver.solved.scale), + height: fov.cameraSize.height * (resolution.height / this.solver.solved.scale), } svg.x += (this.imageInfo.width - svg.width) / 2 @@ -907,30 +962,30 @@ export class ImageComponent implements AfterViewInit, OnDestroy { } deleteFOV(fov: FOV) { - const index = this.fovs.indexOf(fov) + const index = this.fov.fovs.indexOf(fov) if (index >= 0) { - if (this.fovs[index] === this.editedFOV) { - this.editedFOV = undefined + if (this.fov.fovs[index] === this.fov.edited) { + this.fov.edited = undefined } - this.fovs.splice(index, 1) - this.preference.imageFOVs.set(this.fovs) + this.fov.fovs.splice(index, 1) + this.preference.imageFOVs.set(this.fov.fovs) } } private loadPreference() { const preference = this.preference.imagePreference.get() - this.solverRadius = preference.solverRadius ?? this.solverRadius - this.solverType = preference.solverType ?? this.solverTypes[0] - this.fovs = this.preference.imageFOVs.get() - this.fovs.forEach(e => { e.enabled = false; e.computed = undefined }) + this.solver.radius = preference.solverRadius ?? this.solver.radius + this.solver.type = preference.solverType ?? this.solver.types[0] + this.fov.fovs = this.preference.imageFOVs.get() + this.fov.fovs.forEach(e => { e.enabled = false; e.computed = undefined }) } private savePreference() { const preference: ImagePreference = { - solverRadius: this.solverRadius, - solverType: this.solverType + solverRadius: this.solver.radius, + solverType: this.solver.type } this.preference.imagePreference.set(preference) diff --git a/desktop/src/app/settings/settings.component.ts b/desktop/src/app/settings/settings.component.ts index 428981c3c..4dc16bd05 100644 --- a/desktop/src/app/settings/settings.component.ts +++ b/desktop/src/app/settings/settings.component.ts @@ -7,7 +7,7 @@ import { ElectronService } from '../../shared/services/electron.service' import { PreferenceService } from '../../shared/services/preference.service' import { PrimeService } from '../../shared/services/prime.service' import { EMPTY_LOCATION, Location } from '../../shared/types/atlas.types' -import { DEFAULT_SOLVER_TYPES, DatabaseEntry, PlateSolverOptions, PlateSolverType } from '../../shared/types/settings.types' +import { DEFAULT_SOLVER_TYPES, DatabaseEntry, PlateSolverPreference, PlateSolverType } from '../../shared/types/settings.types' import { compareBy, textComparator } from '../../shared/utils/comparators' import { AppComponent } from '../app.component' @@ -23,7 +23,7 @@ export class SettingsComponent implements AfterViewInit, OnDestroy { readonly solverTypes = Array.from(DEFAULT_SOLVER_TYPES) solverType = this.solverTypes[0] - readonly solvers = new Map() + readonly solvers = new Map() readonly database: DatabaseEntry[] = [] databaseEntry?: DatabaseEntry @@ -58,7 +58,7 @@ export class SettingsComponent implements AfterViewInit, OnDestroy { this.location = preference.selectedLocation.get(this.locations[0]) for (const type of this.solverTypes) { - this.solvers.set(type, preference.plateSolverOptions(type).get()) + this.solvers.set(type, preference.plateSolverPreference(type).get()) } for (let i = 0; i < localStorage.length; i++) { @@ -151,7 +151,7 @@ export class SettingsComponent implements AfterViewInit, OnDestroy { save() { for (const type of this.solverTypes) { - this.preference.plateSolverOptions(type).set(this.solvers.get(type)!) + this.preference.plateSolverPreference(type).set(this.solvers.get(type)!) } } } \ No newline at end of file diff --git a/desktop/src/shared/services/api.service.ts b/desktop/src/shared/services/api.service.ts index eda61dc42..20ac87aef 100644 --- a/desktop/src/shared/services/api.service.ts +++ b/desktop/src/shared/services/api.service.ts @@ -10,10 +10,10 @@ 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 } from '../types/home.types' -import { CoordinateInterpolation, DetectedStar, FOVCamera, FOVTelescope, ImageAnnotation, ImageChannel, ImageInfo, ImageSolved, SCNRProtectionMethod } from '../types/image.types' +import { CoordinateInterpolation, DetectedStar, FOVCamera, FOVTelescope, ImageAnnotation, ImageInfo, ImageSave, ImageSolved, ImageTransformation } from '../types/image.types' import { CelestialLocationType, Mount, SlewRate, TrackMode } from '../types/mount.types' import { SequencePlan } from '../types/sequencer.types' -import { PlateSolverOptions } from '../types/settings.types' +import { PlateSolverPreference } from '../types/settings.types' import { FilterWheel } from '../types/wheel.types' import { HttpService } from './http.service' @@ -314,29 +314,10 @@ export class ApiService { // IMAGE - async openImage( - path: string, - camera?: Camera, - calibrate: boolean = false, - debayer: boolean = false, - force: boolean = false, - autoStretch: boolean = true, - shadow: number = 0, - highlight: number = 1, - midtone: number = 0.5, - mirrorHorizontal: boolean = false, - mirrorVertical: boolean = false, - invert: boolean = false, - scnrEnabled: boolean = false, - scnrChannel: ImageChannel = 'GREEN', - scnrAmount: number = 0.5, - scnrProtectionMode: SCNRProtectionMethod = 'AVERAGE_NEUTRAL', - ) { - const query = this.http.query({ path, camera: camera?.name, calibrate, force, debayer, autoStretch, shadow, highlight, midtone, mirrorHorizontal, mirrorVertical, invert, scnrEnabled, scnrChannel, scnrAmount, scnrProtectionMode }) - const response = await this.http.getBlob(`image?${query}`) - + async openImage(path: string, transformation: ImageTransformation, camera?: Camera) { + const query = this.http.query({ path, camera: camera?.name }) + const response = await this.http.postBlob(`image?${query}`, transformation) const info = JSON.parse(response.headers.get('X-Image-Info')!) as ImageInfo - return { info, blob: response.body! } } @@ -474,9 +455,9 @@ export class ApiService { return this.http.get(`image/annotations?${query}`) } - saveImageAs(inputPath: string, outputPath: string) { - const query = this.http.query({ inputPath, outputPath }) - return this.http.put(`image/save-as?${query}`) + saveImageAs(path: string, save: ImageSave, camera?: Camera) { + const query = this.http.query({ path, camera: camera?.name }) + return this.http.put(`image/save-as?${query}`, save) } coordinateInterpolation(path: string) { @@ -588,10 +569,10 @@ export class ApiService { // SOLVER solveImage( - options: PlateSolverOptions, path: string, blind: boolean, + solver: PlateSolverPreference, path: string, blind: boolean, centerRA: Angle, centerDEC: Angle, radius: Angle, ) { - const query = this.http.query({ ...options, path, blind, centerRA, centerDEC, radius }) + const query = this.http.query({ ...solver, path, blind, centerRA, centerDEC, radius }) return this.http.put(`plate-solver?${query}`) } diff --git a/desktop/src/shared/services/electron.service.ts b/desktop/src/shared/services/electron.service.ts index 44b79941e..055645d14 100644 --- a/desktop/src/shared/services/electron.service.ts +++ b/desktop/src/shared/services/electron.service.ts @@ -110,7 +110,7 @@ export class ElectronService { return this.send('FILE.SAVE', data) } - openFits(data?: OpenFile): Promise { + openImage(data?: OpenFile): Promise { return this.openFile({ ...data, filters: [ { name: 'All', extensions: ['fits', 'fit', 'xisf'] }, @@ -120,14 +120,14 @@ export class ElectronService { }) } - saveFits(data?: OpenFile) { + saveImage(data?: OpenFile) { return this.saveFile({ ...data, filters: [ - { name: 'All', extensions: ['fits', 'fit', 'xisf', 'png', 'jpe?g'] }, + { name: 'All', extensions: ['fits', 'fit', 'xisf', 'png', 'jpg', 'jpeg'] }, { name: 'FITS', extensions: ['fits', 'fit'] }, { name: 'XISF', extensions: ['xisf'] }, - { name: 'Image', extensions: ['png', 'jpe?g'] }, + { name: 'Image', extensions: ['png', 'jpg', 'jpeg'] }, ] }) } diff --git a/desktop/src/shared/services/http.service.ts b/desktop/src/shared/services/http.service.ts index 7f3296722..bba84ae88 100644 --- a/desktop/src/shared/services/http.service.ts +++ b/desktop/src/shared/services/http.service.ts @@ -30,6 +30,10 @@ export class HttpService { return firstValueFrom(this.http.post(`${this.baseUrl}/${path}?${query}`, null)) } + postBlob(path: string, body?: any) { + return firstValueFrom(this.http.post(`${this.baseUrl}/${path}`, body, { observe: 'response', responseType: 'blob' })) + } + patch(path: string, body?: any) { return firstValueFrom(this.http.patch(`${this.baseUrl}/${path}`, body)) } @@ -43,6 +47,10 @@ export class HttpService { return firstValueFrom(this.http.put(`${this.baseUrl}/${path}?${query}`, null)) } + putBlob(path: string, body?: any) { + return firstValueFrom(this.http.put(`${this.baseUrl}/${path}`, body, { observe: 'response', responseType: 'blob' })) + } + delete(path: string) { return firstValueFrom(this.http.delete(`${this.baseUrl}/${path}`)) } diff --git a/desktop/src/shared/services/preference.service.ts b/desktop/src/shared/services/preference.service.ts index 72606f75f..8416fc39b 100644 --- a/desktop/src/shared/services/preference.service.ts +++ b/desktop/src/shared/services/preference.service.ts @@ -2,12 +2,13 @@ import { Injectable } from '@angular/core' import { SkyAtlasPreference } from '../../app/atlas/atlas.component' import { AlignmentPreference, EMPTY_ALIGNMENT_PREFERENCE } from '../types/alignment.types' import { EMPTY_LOCATION, Location } from '../types/atlas.types' +import { CalibrationPreference } from '../types/calibration.types' import { Camera, CameraPreference, CameraStartCapture, EMPTY_CAMERA_PREFERENCE } from '../types/camera.types' import { Device } from '../types/device.types' import { Focuser } from '../types/focuser.types' -import { ConnectionDetails, Equipment } from '../types/home.types' +import { ConnectionDetails, Equipment, HomePreference } from '../types/home.types' import { EMPTY_IMAGE_PREFERENCE, FOV, ImagePreference } from '../types/image.types' -import { EMPTY_PLATE_SOLVER_OPTIONS, PlateSolverOptions, PlateSolverType } from '../types/settings.types' +import { EMPTY_PLATE_SOLVER_PREFERENCE, PlateSolverPreference, PlateSolverType } from '../types/settings.types' import { FilterWheel, WheelPreference } from '../types/wheel.types' import { LocalStorageService } from './local-storage.service' @@ -61,8 +62,8 @@ export class PreferenceService { return new PreferenceData(this.storage, `camera.${camera.name}.tppa`, () => this.cameraPreference(camera).get()) } - plateSolverOptions(type: PlateSolverType) { - return new PreferenceData(this.storage, `settings.plateSolver.${type}`, () => { ...EMPTY_PLATE_SOLVER_OPTIONS, type }) + plateSolverPreference(type: PlateSolverType) { + return new PreferenceData(this.storage, `plateSolver.${type}`, () => { ...EMPTY_PLATE_SOLVER_PREFERENCE, type }) } equipmentForDevice(device: Device) { @@ -73,12 +74,13 @@ export class PreferenceService { return new PreferenceData(this.storage, `focusOffset.${wheel.name}.${position}.${focuser.name}`, () => 0) } + readonly connections = new PreferenceData(this.storage, 'home.connections', () => []) readonly locations = new PreferenceData(this.storage, 'locations', () => [structuredClone(EMPTY_LOCATION)]) readonly selectedLocation = new PreferenceData(this.storage, 'locations.selected', () => structuredClone(EMPTY_LOCATION)) + readonly homePreference = new PreferenceData(this.storage, 'home', () => {}) readonly imagePreference = new PreferenceData(this.storage, 'image', () => structuredClone(EMPTY_IMAGE_PREFERENCE)) readonly skyAtlasPreference = new PreferenceData(this.storage, 'atlas', () => {}) readonly alignmentPreference = new PreferenceData(this.storage, 'alignment', () => structuredClone(EMPTY_ALIGNMENT_PREFERENCE)) - readonly connections = new PreferenceData(this.storage, 'home.connections', () => []) - readonly homeImageDirectory = new PreferenceData(this.storage, 'home.image.directory', '') readonly imageFOVs = new PreferenceData(this.storage, 'image.fovs', () => []) + readonly calibrationPreference = new PreferenceData(this.storage, 'calibration', () => {}) } \ No newline at end of file diff --git a/desktop/src/shared/types/alignment.types.ts b/desktop/src/shared/types/alignment.types.ts index 90c9333c9..0473c2a2d 100644 --- a/desktop/src/shared/types/alignment.types.ts +++ b/desktop/src/shared/types/alignment.types.ts @@ -1,7 +1,7 @@ import { Angle } from './atlas.types' import { CameraStartCapture } from './camera.types' import { GuideDirection } from './guider.types' -import { PlateSolverOptions, PlateSolverType } from './settings.types' +import { PlateSolverPreference, PlateSolverType } from './settings.types' export type Hemisphere = 'NORTHERN' | 'SOUTHERN' @@ -53,7 +53,7 @@ export interface DARVElapsed extends MessageEvent { export interface TPPAStart { capture: CameraStartCapture - plateSolver: PlateSolverOptions + plateSolver: PlateSolverPreference startFromCurrentPosition: boolean eastDirection: boolean compensateRefraction: boolean diff --git a/desktop/src/shared/types/calibration.types.ts b/desktop/src/shared/types/calibration.types.ts index 4f5336598..dc38a6f68 100644 --- a/desktop/src/shared/types/calibration.types.ts +++ b/desktop/src/shared/types/calibration.types.ts @@ -21,3 +21,7 @@ export interface CalibrationFrameGroup { key: Omit frames: CalibrationFrame[] } + +export interface CalibrationPreference { + openPath?: string +} diff --git a/desktop/src/shared/types/home.types.ts b/desktop/src/shared/types/home.types.ts index a52fb11d9..c8f4a28d3 100644 --- a/desktop/src/shared/types/home.types.ts +++ b/desktop/src/shared/types/home.types.ts @@ -35,6 +35,10 @@ export interface ConnectionClosed { id: string } +export interface HomePreference { + imagePath?: string +} + export interface Equipment { camera?: Camera guider?: Camera diff --git a/desktop/src/shared/types/image.types.ts b/desktop/src/shared/types/image.types.ts index 01d49d0cc..174ca8784 100644 --- a/desktop/src/shared/types/image.types.ts +++ b/desktop/src/shared/types/image.types.ts @@ -3,13 +3,17 @@ import { Angle, AstronomicalObject, DeepSkyObject, EquatorialCoordinateJ2000, St import { Camera } from './camera.types' import { PlateSolverType } from './settings.types' -export type ImageChannel = 'RED' | 'GREEN' | 'BLUE' | 'GRAY' | 'NONE' +export type ImageChannel = 'RED' | 'GREEN' | 'BLUE' | 'GRAY' export const SCNR_PROTECTION_METHODS = ['MAXIMUM_MASK', 'ADDITIVE_MASK', 'AVERAGE_NEUTRAL', 'MAXIMUM_NEUTRAL', 'MINIMUM_NEUTRAL'] as const export type SCNRProtectionMethod = (typeof SCNR_PROTECTION_METHODS)[number] export type ImageSource = 'FRAMING' | 'PATH' | 'CAMERA' | 'FLAT_WIZARD' +export type ImageFormat = 'FITS' | 'XISF' | 'PNG' | 'JPG' + +export type Bitpix = 'BYTE' | 'SHORT' | 'INTEGER' | 'LONG' | 'FLOAT' | 'DOUBLE' + export interface FITSHeaderItem { name: string value: string @@ -28,6 +32,7 @@ export interface ImageInfo { declination?: Angle solved?: ImageSolved headers: FITSHeaderItem[] + bitpix: Bitpix statistics: ImageStatistics } @@ -40,6 +45,7 @@ export interface ImageAnnotation { } export interface ImageSolved extends EquatorialCoordinateJ2000 { + solved: boolean orientation: number scale: number width: number @@ -48,6 +54,7 @@ export interface ImageSolved extends EquatorialCoordinateJ2000 { } export const EMPTY_IMAGE_SOLVED: ImageSolved = { + solved: false, orientation: 0, scale: 0, width: 0, @@ -82,6 +89,16 @@ export interface ImageStatisticsBitOption { bitLength: number } +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 }, +] as const + export interface ImageStatistics { count: number maxCount: number @@ -98,6 +115,7 @@ export interface ImageStatistics { export interface ImagePreference { solverRadius?: number solverType?: PlateSolverType + savePath?: string } export const EMPTY_IMAGE_PREFERENCE: ImagePreference = { @@ -164,3 +182,79 @@ export interface FOVTelescope extends FOVEquipment { aperture: number focalLength: number } + +export interface ImageSCNR { + showDialog: boolean + channel?: ImageChannel + amount: number + method: SCNRProtectionMethod +} + +export interface ImageDetectStars { + visible: boolean + stars: DetectedStar[] +} + +export interface ImageFITSHeaders { + showDialog: boolean + headers: FITSHeaderItem[] +} + +export interface ImageStretch { + showDialog: boolean + auto: boolean + shadow: number + highlight: number + midtone: number +} + +export interface ImageSolver { + showDialog: boolean + solving: boolean + blind: boolean + centerRA: Angle + centerDEC: Angle + radius: number + readonly solved: ImageSolved + readonly types: PlateSolverType[] + type: PlateSolverType +} + +export interface ImageFOV extends FOV { + showDialog: boolean + fovs: FOV[] + edited?: FOV + showCameraDialog: boolean + cameras: FOVCamera[] + camera?: FOVCamera + showTelescopeDialog: boolean + telescopes: FOVTelescope[] + telescope?: FOVTelescope +} + +export interface ImageROI { + x: number + y: number + width: number + height: number +} + +export interface ImageSave { + showDialog: boolean + format: ImageFormat + bitpix: Bitpix + shouldBeTransformed: boolean + transformation: ImageTransformation + path: string +} + +export interface ImageTransformation { + force: boolean + calibrate: boolean + debayer: boolean + stretch: Omit + mirrorHorizontal: boolean + mirrorVertical: boolean + invert: boolean + scnr: Pick +} \ No newline at end of file diff --git a/desktop/src/shared/types/settings.types.ts b/desktop/src/shared/types/settings.types.ts index 5abda4196..2b5ad920d 100644 --- a/desktop/src/shared/types/settings.types.ts +++ b/desktop/src/shared/types/settings.types.ts @@ -4,7 +4,7 @@ export type PlateSolverType = 'ASTROMETRY_NET' | 'ASTROMETRY_NET_ONLINE' | 'ASTA export const DEFAULT_SOLVER_TYPES: PlateSolverType[] = ['ASTROMETRY_NET_ONLINE', 'ASTAP'] -export interface PlateSolverOptions { +export interface PlateSolverPreference { type: PlateSolverType executablePath: string downsampleFactor: number @@ -13,8 +13,8 @@ export interface PlateSolverOptions { timeout: number } -export const EMPTY_PLATE_SOLVER_OPTIONS: PlateSolverOptions = { - type: 'ASTROMETRY_NET_ONLINE', +export const EMPTY_PLATE_SOLVER_PREFERENCE: PlateSolverPreference = { + type: 'ASTAP', executablePath: '', downsampleFactor: 0, apiUrl: 'https://nova.astrometry.net/', diff --git a/nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsFormat.kt b/nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsFormat.kt index 95e648f7a..33fc460f2 100644 --- a/nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsFormat.kt +++ b/nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsFormat.kt @@ -66,8 +66,11 @@ data object FitsFormat : ImageFormat { val numberOfChannels = header.numberOfChannels val bitpix = header.bitpix val position = source.position + val rangeMin = header.getFloat(FitsKeyword.DATAMIN, 0f) + val rangeMax = header.getFloat(FitsKeyword.DATAMAX, 1f) + val range = rangeMin..rangeMax - val data = SeekableSourceImageData(source, position, width, height, numberOfChannels, bitpix) + val data = SeekableSourceImageData(source, position, width, height, numberOfChannels, bitpix, range) val skipBytes = computeRemainingBytesToSkip(data.totalSizeInBytes) if (skipBytes > 0L) source.seek(position + data.totalSizeInBytes + skipBytes) @@ -99,10 +102,11 @@ data object FitsFormat : ImageFormat { return hdus } - fun writeHeader(header: ReadableHeader, sink: Sink) { + fun writeHeader(header: ReadableHeader, bitpix: Bitpix, sink: Sink) { Buffer().use { buffer -> for (card in header) { - buffer.writeString(card.formatted(), Charsets.US_ASCII) + if (card.key == bitpix.key) buffer.writeCard(bitpix) + else buffer.writeCard(card) } if (header.last().key != FitsHeaderCard.END.key) { @@ -115,8 +119,7 @@ data object FitsFormat : ImageFormat { } } - fun writeImageData(data: ImageData, header: ReadableHeader, sink: Sink) { - val bitpix = header.bitpix + fun writeImageData(data: ImageData, bitpix: Bitpix, sink: Sink) { val channels = arrayOf(data.red, data.green, data.blue) var byteCount = 0L @@ -141,11 +144,15 @@ data object FitsFormat : ImageFormat { } } - override fun write(sink: Sink, hdus: Iterable>) { + override fun write(sink: Sink, hdus: Iterable>, modifier: ImageModifier) { + val bitpix = modifier.bitpix() + for (hdu in hdus) { if (hdu is ImageHdu) { - writeHeader(hdu.header, sink) - writeImageData(hdu.data, hdu.header, sink) + with(bitpix ?: hdu.header.bitpix) { + writeHeader(hdu.header, this, sink) + writeImageData(hdu.data, this, sink) + } } } } @@ -174,5 +181,10 @@ data object FitsFormat : ImageFormat { } } + @JvmStatic + private fun Buffer.writeCard(card: HeaderCard) { + writeString(card.formatted(), Charsets.US_ASCII) + } + @JvmStatic private val LOG = loggerFor() } diff --git a/nebulosa-fits/src/main/kotlin/nebulosa/fits/Modifiers.kt b/nebulosa-fits/src/main/kotlin/nebulosa/fits/Modifiers.kt new file mode 100644 index 000000000..dd3ca55e9 --- /dev/null +++ b/nebulosa-fits/src/main/kotlin/nebulosa/fits/Modifiers.kt @@ -0,0 +1,13 @@ +package nebulosa.fits + +import nebulosa.image.format.ImageModifier + +private data class WithBitpix(@JvmField val value: Bitpix) : ImageModifier.Element + +fun ImageModifier.bitpix(value: Bitpix) = then(WithBitpix(value)) + +fun ImageModifier.bitpix(): Bitpix? { + var bitpix: Bitpix? = null + foldIn { if (it is WithBitpix) bitpix = it.value } + return bitpix +} diff --git a/nebulosa-fits/src/main/kotlin/nebulosa/fits/SeekableSourceImageData.kt b/nebulosa-fits/src/main/kotlin/nebulosa/fits/SeekableSourceImageData.kt index 4a7fc10ae..8733dde85 100644 --- a/nebulosa-fits/src/main/kotlin/nebulosa/fits/SeekableSourceImageData.kt +++ b/nebulosa-fits/src/main/kotlin/nebulosa/fits/SeekableSourceImageData.kt @@ -4,8 +4,10 @@ import nebulosa.fits.FitsFormat.readPixel import nebulosa.image.format.ImageChannel import nebulosa.image.format.ImageData import nebulosa.io.SeekableSource +import nebulosa.log.loggerFor import okio.Buffer import okio.Sink +import kotlin.math.max import kotlin.math.min @Suppress("NOTHING_TO_INLINE") @@ -16,6 +18,7 @@ internal data class SeekableSourceImageData( override val height: Int, override val numberOfChannels: Int, private val bitpix: Bitpix, + private val range: ClosedFloatingPointRange, ) : ImageData { @JvmField internal val channelSizeInBytes = (numberOfPixels * bitpix.byteLength).toLong() @@ -72,6 +75,9 @@ internal data class SeekableSourceImageData( var pos = 0 Buffer().use { buffer -> + var min = Float.MAX_VALUE + var max = Float.MIN_VALUE + while (remainingPixels > 0) { var n = min(PIXEL_COUNT, remainingPixels) val byteCount = n * bitpix.byteLength.toLong() @@ -84,11 +90,26 @@ internal data class SeekableSourceImageData( n = (size / bitpix.byteLength).toInt() repeat(n) { - output[pos++] = buffer.readPixel(bitpix) + val pixel = buffer.readPixel(bitpix) + if (pixel < min) min = pixel + if (pixel > max) max = pixel + output[pos++] = pixel } remainingPixels -= n } + + if (min < 0f || max > 1f) { + val rangeMin = min(range.start, min) + val rangeMax = max(range.endInclusive, max) + val rangeDelta = rangeMax - rangeMin + + LOG.info("rescaling [{}, {}] to [0, 1]. channel={}, delta={}", rangeMin, rangeMax, channel, rangeDelta) + + for (i in output.indices) { + output[i] = (output[i] - rangeMin) / rangeDelta + } + } } } @@ -123,5 +144,7 @@ internal data class SeekableSourceImageData( companion object { const val PIXEL_COUNT = 64 + + @JvmStatic private val LOG = loggerFor() } } diff --git a/nebulosa-image-format/src/main/kotlin/nebulosa/image/format/ImageFormat.kt b/nebulosa-image-format/src/main/kotlin/nebulosa/image/format/ImageFormat.kt index d24b6db98..891a4d348 100644 --- a/nebulosa-image-format/src/main/kotlin/nebulosa/image/format/ImageFormat.kt +++ b/nebulosa-image-format/src/main/kotlin/nebulosa/image/format/ImageFormat.kt @@ -7,5 +7,5 @@ interface ImageFormat { fun read(source: SeekableSource): List> - fun write(sink: Sink, hdus: Iterable>) + fun write(sink: Sink, hdus: Iterable>, modifier: ImageModifier = ImageModifier) } diff --git a/nebulosa-image-format/src/main/kotlin/nebulosa/image/format/ImageModifier.kt b/nebulosa-image-format/src/main/kotlin/nebulosa/image/format/ImageModifier.kt new file mode 100644 index 000000000..ebd5cb818 --- /dev/null +++ b/nebulosa-image-format/src/main/kotlin/nebulosa/image/format/ImageModifier.kt @@ -0,0 +1,44 @@ +package nebulosa.image.format + +// https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/Modifier.kt + +sealed interface ImageModifier { + + fun foldIn(operation: (Element) -> Unit) + + fun foldOut(operation: (Element) -> Unit) + + infix fun then(other: ImageModifier): ImageModifier = if (other === ImageModifier) this else Combined(this, other) + + interface Element : ImageModifier { + + override fun foldIn(operation: (Element) -> Unit) = operation(this) + + override fun foldOut(operation: (Element) -> Unit) = operation(this) + } + + private data class Combined( + private val outer: ImageModifier, + private val inner: ImageModifier, + ) : ImageModifier { + + override fun foldIn(operation: (Element) -> Unit) { + outer.foldIn(operation) + inner.foldIn(operation) + } + + override fun foldOut(operation: (Element) -> Unit) { + inner.foldOut(operation) + outer.foldOut(operation) + } + } + + companion object : ImageModifier { + + override fun foldIn(operation: (Element) -> Unit) = Unit + + override fun foldOut(operation: (Element) -> Unit) = Unit + + override fun then(other: ImageModifier) = other + } +} diff --git a/nebulosa-image/src/main/kotlin/nebulosa/image/Image.kt b/nebulosa-image/src/main/kotlin/nebulosa/image/Image.kt index 4e2a161aa..3c1aced03 100644 --- a/nebulosa-image/src/main/kotlin/nebulosa/image/Image.kt +++ b/nebulosa-image/src/main/kotlin/nebulosa/image/Image.kt @@ -178,8 +178,8 @@ class Image internal constructor( } } - fun writeTo(sink: Sink, format: ImageFormat) { - format.write(sink, listOf(hdu)) + fun writeTo(sink: Sink, format: ImageFormat, modifier: ImageModifier = ImageModifier) { + format.write(sink, listOf(hdu), modifier) } /** diff --git a/nebulosa-image/src/main/kotlin/nebulosa/image/algorithms/transformation/ScreenTransformFunction.kt b/nebulosa-image/src/main/kotlin/nebulosa/image/algorithms/transformation/ScreenTransformFunction.kt index 8a09f240b..8c9737a48 100644 --- a/nebulosa-image/src/main/kotlin/nebulosa/image/algorithms/transformation/ScreenTransformFunction.kt +++ b/nebulosa-image/src/main/kotlin/nebulosa/image/algorithms/transformation/ScreenTransformFunction.kt @@ -23,7 +23,7 @@ data class ScreenTransformFunction( companion object { - @JvmStatic val EMPTY = Parameters() + @JvmStatic val DEFAULT = Parameters() } } diff --git a/nebulosa-xisf/src/main/kotlin/nebulosa/xisf/XisfFormat.kt b/nebulosa-xisf/src/main/kotlin/nebulosa/xisf/XisfFormat.kt index 6ce39ef86..c97d20646 100644 --- a/nebulosa-xisf/src/main/kotlin/nebulosa/xisf/XisfFormat.kt +++ b/nebulosa-xisf/src/main/kotlin/nebulosa/xisf/XisfFormat.kt @@ -1,11 +1,13 @@ package nebulosa.xisf +import nebulosa.fits.Bitpix import nebulosa.fits.ValueType import nebulosa.fits.bitpix import nebulosa.fits.frame import nebulosa.image.format.Hdu import nebulosa.image.format.ImageFormat import nebulosa.image.format.ImageHdu +import nebulosa.image.format.ImageModifier import nebulosa.io.* import nebulosa.xisf.XisfMonolithicFileHeader.* import nebulosa.xml.escapeXml @@ -53,9 +55,11 @@ data object XisfFormat : ImageFormat { } } - override fun write(sink: Sink, hdus: Iterable>) { + override fun write(sink: Sink, hdus: Iterable>, modifier: ImageModifier) { + val bitpix = modifier.bitpix() + Buffer().use { buffer -> - val headerSize = writeHeader(buffer, hdus) + val headerSize = writeHeader(buffer, hdus, bitpix) val byteCount = buffer.readAll(sink) val remainingBytes = headerSize - byteCount @@ -65,8 +69,7 @@ data object XisfFormat : ImageFormat { for (hdu in hdus) { if (hdu is ImageHdu) { - val bitpix = hdu.header.bitpix - val sampleFormat = SampleFormat.from(bitpix) + val sampleFormat = SampleFormat.from(bitpix ?: hdu.header.bitpix) if (hdu.isMono) { hdu.data.red.writeTo(buffer, sink, sampleFormat) @@ -80,7 +83,7 @@ data object XisfFormat : ImageFormat { } } - private fun writeHeader(buffer: Buffer, hdus: Iterable>, initialHeaderSize: Int = 4096): Int { + private fun writeHeader(buffer: Buffer, hdus: Iterable>, bitpix: Bitpix? = null, initialHeaderSize: Int = 4096): Int { buffer.clear() buffer.writeString(MAGIC_HEADER, Charsets.US_ASCII) @@ -94,10 +97,9 @@ data object XisfFormat : ImageFormat { val header = hdu.header val colorSpace = if (hdu.isMono) ColorSpace.GRAY else ColorSpace.RGB val imageType = ImageType.parse(header.frame ?: "Light") ?: ImageType.LIGHT - val bitpix = hdu.header.bitpix - val sampleFormat = SampleFormat.from(bitpix) + val sampleFormat = SampleFormat.from(bitpix ?: hdu.header.bitpix) val imageSize = hdu.width * hdu.height * hdu.numberOfChannels * sampleFormat.byteLength - val extra = if (bitpix.code < 0) " bounds=\"0:1\"" else "" + val extra = if (sampleFormat.bitpix.code < 0) " bounds=\"0:1\"" else "" IMAGE_START_TAG .format( @@ -125,7 +127,8 @@ data object XisfFormat : ImageFormat { } for (keyword in header) { - val value = if (keyword.isStringType) "'${keyword.value.escapeXml()}'" else keyword.value + val value = if (keyword.key == sampleFormat.bitpix.key) sampleFormat.bitpix.value + else if (keyword.isStringType) "'${keyword.value.escapeXml()}'" else keyword.value buffer.writeUtf8(FITS_KEYWORD_TAG.format(keyword.key, value, keyword.comment.escapeXml())) } } @@ -138,7 +141,7 @@ data object XisfFormat : ImageFormat { val remainingBytes = initialHeaderSize - size if (remainingBytes < 0) { - return writeHeader(buffer, hdus, initialHeaderSize * 2) + return writeHeader(buffer, hdus, bitpix, initialHeaderSize * 2) } val headerSize = size - 16