Skip to content

Commit

Permalink
[api][desktop]: Improve save image
Browse files Browse the repository at this point in the history
  • Loading branch information
tiagohm committed Apr 5, 2024
1 parent 17e5b39 commit 8fb505f
Show file tree
Hide file tree
Showing 13 changed files with 551 additions and 317 deletions.
35 changes: 9 additions & 26 deletions api/src/main/kotlin/nebulosa/api/image/ImageController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -20,41 +18,26 @@ 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) {
return imageService.closeImage(path)
}

@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: ImageSave
) {
imageService.saveImageAs(path, save, camera)
}

@GetMapping("annotations")
Expand Down
8 changes: 8 additions & 0 deletions api/src/main/kotlin/nebulosa/api/image/ImageFormat.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package nebulosa.api.image

enum class ImageFormat {
FITS,
XISF,
PNG,
JPG,
}
2 changes: 2 additions & 0 deletions api/src/main/kotlin/nebulosa/api/image/ImageInfo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -16,6 +17,7 @@ data class ImageInfo(
@field:JsonSerialize(using = DeclinationSerializer::class) val declination: Double? = null,
val solved: ImageSolved? = null,
val headers: List<ImageHeaderItem> = emptyList(),
val bitpix: Bitpix = Bitpix.BYTE,
val camera: Camera? = null,
@JsonIgnoreProperties("histogram") val statistics: Statistics.Data? = null,
)
17 changes: 17 additions & 0 deletions api/src/main/kotlin/nebulosa/api/image/ImageSave.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package nebulosa.api.image

import nebulosa.fits.Bitpix
import java.nio.file.Path

data class ImageSave(
val format: ImageFormat = ImageFormat.FITS,
val bitpix: Bitpix = Bitpix.BYTE,
val transformation: ImageTransformation = ImageTransformation.EMPTY,
val path: Path = EMPTY_PATH,
) {

companion object {

@JvmStatic private val EMPTY_PATH = Path.of("")
}
}
109 changes: 63 additions & 46 deletions api/src/main/kotlin/nebulosa/api/image/ImageService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ 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.indi.device.camera.Camera
import nebulosa.log.debug
import nebulosa.log.loggerFor
Expand Down Expand Up @@ -40,7 +39,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
Expand All @@ -56,6 +54,18 @@ class ImageService(
private val starDetector: StarDetector<Image>,
) {

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()
Expand All @@ -68,41 +78,65 @@ 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(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(
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
|| 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 (transformation.calibrate && instrument != null) {
transformedImage = calibrationFrameService.calibrate(instrument.name, transformedImage, transformedImage === this)
}

if (mirrorHorizontal) {
if (transformation.mirrorHorizontal) {
transformedImage = HorizontalFlip.transform(transformedImage)
}
if (mirrorVertical) {
if (transformation.mirrorVertical) {
transformedImage = VerticalFlip.transform(transformedImage)
}

if (scnrEnabled) {
transformedImage = SubtractiveChromaticNoiseReduction(scnrChannel, scnrAmount, scnrProtectionMode).transform(transformedImage)
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)
Expand All @@ -111,26 +145,11 @@ class ImageService(
transformedImage = ScreenTransformFunction(stretchParams).transform(transformedImage)
}

if (invert) {
if (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
Expand Down Expand Up @@ -244,18 +263,15 @@ 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: ImageSave, camera: Camera?) {
val (image) = imageBucket[inputPath]?.first?.transform(save.transformation, ImageOperation.SAVE)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Image not found")

when (save.format) {
ImageFormat.FITS -> save.path.sink().use { image.writeTo(it, FitsFormat) }
ImageFormat.XISF -> save.path.sink().use { image.writeTo(it, XisfFormat) }
ImageFormat.PNG -> save.path.outputStream().use { ImageIO.write(image, "PNG", it) }
ImageFormat.JPG -> save.path.outputStream().use { ImageIO.write(image, "JPEG", it) }
}
}

Expand Down Expand Up @@ -320,6 +336,7 @@ class ImageService(

@JvmStatic private val LOG = loggerFor<ImageService>()

private const val IMAGE_INFO_HEADER = "X-Image-Info"
private const val COORDINATE_INTERPOLATION_DELTA = 24
}
}
46 changes: 46 additions & 0 deletions api/src/main/kotlin/nebulosa/api/image/ImageTransformation.kt
Original file line number Diff line number Diff line change
@@ -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()
}
}
2 changes: 2 additions & 0 deletions desktop/app/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,8 @@ function createWindow(options: OpenWindow<any>, parent?: BrowserWindow) {

browserWindows.set(id, window)

console.info('window created: ', id, window.id)

return window
}

Expand Down
Loading

0 comments on commit 8fb505f

Please sign in to comment.