Skip to content

Commit

Permalink
[api][desktop]: Implement save as XISF
Browse files Browse the repository at this point in the history
  • Loading branch information
tiagohm committed Apr 1, 2024
1 parent fa94428 commit 2e18a9f
Show file tree
Hide file tree
Showing 20 changed files with 490 additions and 270 deletions.
27 changes: 13 additions & 14 deletions api/src/main/kotlin/nebulosa/api/image/ImageService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import nebulosa.image.algorithms.computation.Statistics
import nebulosa.image.algorithms.transformation.*
import nebulosa.image.format.ImageChannel
import nebulosa.indi.device.camera.Camera
import nebulosa.io.transferAndClose
import nebulosa.log.debug
import nebulosa.log.loggerFor
import nebulosa.math.*
Expand All @@ -29,6 +28,8 @@ import nebulosa.time.TimeYMDHMS
import nebulosa.time.UTC
import nebulosa.wcs.WCS
import nebulosa.wcs.WCSException
import nebulosa.xisf.XisfFormat
import okio.sink
import org.springframework.http.HttpStatus
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor
import org.springframework.stereotype.Service
Expand All @@ -40,7 +41,6 @@ import java.util.*
import java.util.concurrent.CompletableFuture
import javax.imageio.ImageIO
import kotlin.io.path.extension
import kotlin.io.path.inputStream
import kotlin.io.path.outputStream

@Service
Expand Down Expand Up @@ -81,8 +81,8 @@ class ImageService(
var stretchParams = ScreenTransformFunction.Parameters(midtone, shadow, highlight)

val shouldBeTransformed = autoStretch || manualStretch
|| mirrorHorizontal || mirrorVertical || invert
|| scnrEnabled
|| mirrorHorizontal || mirrorVertical || invert
|| scnrEnabled

var transformedImage = if (shouldBeTransformed) image.clone() else image
val instrument = camera?.name ?: image.header.instrument
Expand Down Expand Up @@ -246,16 +246,15 @@ class ImageService(

fun saveImageAs(inputPath: Path, outputPath: Path) {
if (inputPath != outputPath) {
if (inputPath.extension == outputPath.extension) {
inputPath.inputStream().transferAndClose(outputPath.outputStream())
} else {
val image = imageBucket[inputPath]?.first ?: return

when (outputPath.extension.uppercase()) {
"PNG" -> outputPath.outputStream().use { ImageIO.write(image, "PNG", it) }
"JPG", "JPEG" -> outputPath.outputStream().use { ImageIO.write(image, "JPEG", it) }
else -> throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid format")
}
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")
}
}
}
Expand Down
11 changes: 7 additions & 4 deletions desktop/src/shared/services/electron.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,9 @@ export class ElectronService {
openFits(data?: OpenFile): Promise<string | undefined> {
return this.openFile({
...data, filters: [
{ name: 'FITS files', extensions: ['fits', 'fit'] },
{ name: 'XISF files', extensions: ['xisf'] },
{ name: 'All', extensions: ['fits', 'fit', 'xisf'] },
{ name: 'FITS', extensions: ['fits', 'fit'] },
{ name: 'XISF', extensions: ['xisf'] },
]
})
}
Expand All @@ -123,8 +124,10 @@ export class ElectronService {
return this.saveFile({
...data,
filters: [
{ name: 'FITS files', extensions: ['fits', 'fit'] },
{ name: 'Image files', extensions: ['png', 'jpe?g'] },
{ name: 'All', extensions: ['fits', 'fit', 'xisf', 'png', 'jpe?g'] },
{ name: 'FITS', extensions: ['fits', 'fit'] },
{ name: 'XISF', extensions: ['xisf'] },
{ name: 'Image', extensions: ['png', 'jpe?g'] },
]
})
}
Expand Down
22 changes: 12 additions & 10 deletions nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsFormat.kt
Original file line number Diff line number Diff line change
Expand Up @@ -149,25 +149,27 @@ data object FitsFormat : ImageFormat {
}
}

@JvmStatic
internal fun Buffer.readPixel(bitpix: Bitpix): Float {
return when (bitpix) {
Bitpix.BYTE -> (buffer.readByte().toInt() and 0xFF) / 255f
Bitpix.SHORT -> (buffer.readShort().toInt() + 32768) / 65535f
Bitpix.INTEGER -> ((buffer.readInt().toLong() + 2147483648L) / 4294967295.0).toFloat()
Bitpix.BYTE -> (readByte().toInt() and 0xFF) / 255f
Bitpix.SHORT -> (readShort().toInt() + 32768) / 65535f
Bitpix.INTEGER -> ((readInt().toLong() + 2147483648L) / 4294967295.0).toFloat()
Bitpix.LONG -> TODO("Unsupported UInt64 sample format")
Bitpix.FLOAT -> buffer.readFloat()
Bitpix.DOUBLE -> buffer.readDouble().toFloat()
Bitpix.FLOAT -> readFloat()
Bitpix.DOUBLE -> readDouble().toFloat()
}
}

@JvmStatic
internal fun Buffer.writePixel(pixel: Float, bitpix: Bitpix) {
when (bitpix) {
Bitpix.BYTE -> buffer.writeByte((pixel * 255f).toInt())
Bitpix.SHORT -> buffer.writeShort((pixel * 65535f).toInt())
Bitpix.INTEGER -> buffer.writeInt((pixel * 4294967295.0).toInt())
Bitpix.BYTE -> writeByte((pixel * 255f).toInt())
Bitpix.SHORT -> writeShort((pixel * 65535f).toInt())
Bitpix.INTEGER -> writeInt((pixel * 4294967295.0).toInt())
Bitpix.LONG -> TODO("Unsupported UInt64 sample format")
Bitpix.FLOAT -> buffer.writeFloat(pixel)
Bitpix.DOUBLE -> buffer.writeDouble(pixel.toDouble())
Bitpix.FLOAT -> writeFloat(pixel)
Bitpix.DOUBLE -> writeDouble(pixel.toDouble())
}
}

Expand Down
3 changes: 3 additions & 0 deletions nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsHeader.kt
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ open class FitsHeader : AbstractHeader {

final override fun add(card: HeaderCard) = Unit

final override fun addAll(cards: Iterable<HeaderCard>) = Unit

final override fun delete(key: HeaderKey) = false

final override fun delete(key: String) = false
Expand All @@ -102,6 +104,7 @@ open class FitsHeader : AbstractHeader {
const val MIN_COMMENT_ALIGN = 20
const val MAX_COMMENT_ALIGN = 70

@JvmStatic val EMPTY = ReadOnly()
@JvmStatic private val LOG = loggerFor<FitsHeader>()

var commentAlignPosition = DEFAULT_COMMENT_ALIGN
Expand Down
14 changes: 8 additions & 6 deletions nebulosa-fits/src/main/kotlin/nebulosa/fits/FitsHeaderCard.kt
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ data class FitsHeaderCard(
const val MAX_LONG_STRING_CONTINUE_OVERHEAD = 3
const val HIERARCH_WITH_DOT = "HIERARCH."

@JvmStatic val SIMPLE = create(FitsKeyword.SIMPLE, true)
@JvmStatic val END = FitsHeaderCard("END", "", "", FitsHeaderCardType.NONE)
@JvmStatic val EXTENDED = create(FitsKeyword.EXTEND, true)

@JvmStatic
fun from(source: Buffer): FitsHeaderCard {
Expand All @@ -75,32 +77,32 @@ data class FitsHeaderCard(

@JvmStatic
fun create(header: HeaderKey, value: Int): FitsHeaderCard {
return create(header.key, "$value", header.comment)
return create(header.key, value, header.comment)
}

@JvmStatic
fun create(header: HeaderKey, value: Long): FitsHeaderCard {
return create(header.key, "$value", header.comment)
return create(header.key, value, header.comment)
}

@JvmStatic
fun create(header: HeaderKey, value: BigInteger): FitsHeaderCard {
return create(header.key, "$value", header.comment)
return create(header.key, value, header.comment)
}

@JvmStatic
fun create(header: HeaderKey, value: Float): FitsHeaderCard {
return create(header.key, "$value", header.comment)
return create(header.key, value, header.comment)
}

@JvmStatic
fun create(header: HeaderKey, value: Double): FitsHeaderCard {
return create(header.key, "$value", header.comment)
return create(header.key, value, header.comment)
}

@JvmStatic
fun create(key: HeaderKey, value: BigDecimal, comment: String = ""): FitsHeaderCard {
return create(key.key, "$value", comment)
return create(key.key, value, comment)
}

@JvmStatic
Expand Down
20 changes: 10 additions & 10 deletions nebulosa-fits/src/test/kotlin/FitsReadTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,70 +10,70 @@ class FitsReadTest : FitsStringSpec() {

init {
"mono:8-bit" {
val hdu = NGC3344_MONO_8_FITS_PATH.fits().filterIsInstance<ImageHdu>().first()
val hdu = NGC3344_MONO_8_FITS.fits().filterIsInstance<ImageHdu>().first()
hdu.width shouldBeExactly 256
hdu.height shouldBeExactly 256
hdu.numberOfChannels shouldBeExactly 1
hdu.header.bitpix shouldBe Bitpix.BYTE
}
"mono:16-bit" {
val hdu = NGC3344_MONO_16_FITS_PATH.fits().filterIsInstance<ImageHdu>().first()
val hdu = NGC3344_MONO_16_FITS.fits().filterIsInstance<ImageHdu>().first()
hdu.width shouldBeExactly 256
hdu.height shouldBeExactly 256
hdu.numberOfChannels shouldBeExactly 1
hdu.header.bitpix shouldBe Bitpix.SHORT
}
"mono:32-bit" {
val hdu = NGC3344_MONO_32_FITS_PATH.fits().filterIsInstance<ImageHdu>().first()
val hdu = NGC3344_MONO_32_FITS.fits().filterIsInstance<ImageHdu>().first()
hdu.width shouldBeExactly 256
hdu.height shouldBeExactly 256
hdu.numberOfChannels shouldBeExactly 1
hdu.header.bitpix shouldBe Bitpix.INTEGER
}
"mono:32-bit floating-point" {
val hdu = NGC3344_MONO_F32_FITS_PATH.fits().filterIsInstance<ImageHdu>().first()
val hdu = NGC3344_MONO_F32_FITS.fits().filterIsInstance<ImageHdu>().first()
hdu.width shouldBeExactly 256
hdu.height shouldBeExactly 256
hdu.numberOfChannels shouldBeExactly 1
hdu.header.bitpix shouldBe Bitpix.FLOAT
}
"mono:64-bit floating-point" {
val hdu = NGC3344_MONO_F64_FITS_PATH.fits().filterIsInstance<ImageHdu>().first()
val hdu = NGC3344_MONO_F64_FITS.fits().filterIsInstance<ImageHdu>().first()
hdu.width shouldBeExactly 256
hdu.height shouldBeExactly 256
hdu.numberOfChannels shouldBeExactly 1
hdu.header.bitpix shouldBe Bitpix.DOUBLE
}
"color:8-bit" {
val hdu = NGC3344_COLOR_8_FITS_PATH.fits().filterIsInstance<ImageHdu>().first()
val hdu = NGC3344_COLOR_8_FITS.fits().filterIsInstance<ImageHdu>().first()
hdu.width shouldBeExactly 256
hdu.height shouldBeExactly 256
hdu.numberOfChannels shouldBeExactly 4
hdu.header.bitpix shouldBe Bitpix.BYTE
}
"color:16-bit" {
val hdu = NGC3344_COLOR_16_FITS_PATH.fits().filterIsInstance<ImageHdu>().first()
val hdu = NGC3344_COLOR_16_FITS.fits().filterIsInstance<ImageHdu>().first()
hdu.width shouldBeExactly 256
hdu.height shouldBeExactly 256
hdu.numberOfChannels shouldBeExactly 4
hdu.header.bitpix shouldBe Bitpix.SHORT
}
"color:32-bit" {
val hdu = NGC3344_COLOR_32_FITS_PATH.fits().filterIsInstance<ImageHdu>().first()
val hdu = NGC3344_COLOR_32_FITS.fits().filterIsInstance<ImageHdu>().first()
hdu.width shouldBeExactly 256
hdu.height shouldBeExactly 256
hdu.numberOfChannels shouldBeExactly 4
hdu.header.bitpix shouldBe Bitpix.INTEGER
}
"color:32-bit floating-point" {
val hdu = NGC3344_COLOR_F32_FITS_PATH.fits().filterIsInstance<ImageHdu>().first()
val hdu = NGC3344_COLOR_F32_FITS.fits().filterIsInstance<ImageHdu>().first()
hdu.width shouldBeExactly 256
hdu.height shouldBeExactly 256
hdu.numberOfChannels shouldBeExactly 4
hdu.header.bitpix shouldBe Bitpix.FLOAT
}
"color:64-bit floating-point" {
val hdu = NGC3344_COLOR_F64_FITS_PATH.fits().filterIsInstance<ImageHdu>().first()
val hdu = NGC3344_COLOR_F64_FITS.fits().filterIsInstance<ImageHdu>().first()
hdu.width shouldBeExactly 256
hdu.height shouldBeExactly 256
hdu.numberOfChannels shouldBeExactly 4
Expand Down
2 changes: 1 addition & 1 deletion nebulosa-fits/src/test/kotlin/FitsWriteTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ class FitsWriteTest : FitsStringSpec() {

init {
"mono" {
val hdu0 = NGC3344_MONO_8_FITS_PATH.fits().filterIsInstance<ImageHdu>().first()
val hdu0 = NGC3344_MONO_8_FITS.fits().filterIsInstance<ImageHdu>().first()
val data = ByteArray(69120)
FitsFormat.write(data.sink(), listOf(hdu0))
data.toByteString(2880, 66240).md5().hex() shouldBe "e1735e21c94dc49885fabc429406e573"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ interface WritableHeader {

fun add(card: HeaderCard)

fun addAll(cards: Iterable<HeaderCard>) = cards.forEach(::add)

fun delete(key: HeaderKey) = delete(key.key)

fun delete(key: String): Boolean
Expand Down
8 changes: 4 additions & 4 deletions nebulosa-image/src/test/kotlin/ComputationAlgorithmTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ class ComputationAlgorithmTest : FitsStringSpec() {

init {
"mono:median absolute deviation" {
val mImage = Image.open(NGC3344_MONO_F32_FITS_PATH.fits())
val mImage = Image.open(NGC3344_MONO_F32_FITS.fits())
mImage.compute(MedianAbsoluteDeviation()) shouldBe (0.0862f plusOrMinus 1e-4f)
}
"mono:statistics" {
val mImage = Image.open(NGC3344_MONO_F32_FITS_PATH.fits())
val mImage = Image.open(NGC3344_MONO_F32_FITS.fits())
val statistics = mImage.compute(Statistics.GRAY)

statistics.count shouldBeExactly 65536
Expand All @@ -32,13 +32,13 @@ class ComputationAlgorithmTest : FitsStringSpec() {
statistics.maximum shouldBeExactly 1f
}
"color:median absolute deviation" {
val cImage = Image.open(NGC3344_COLOR_F32_FITS_PATH.fits())
val cImage = Image.open(NGC3344_COLOR_F32_FITS.fits())
cImage.compute(MedianAbsoluteDeviation(channel = ImageChannel.RED)) shouldBe (0.0823f plusOrMinus 1e-4f)
cImage.compute(MedianAbsoluteDeviation(channel = ImageChannel.GREEN)) shouldBe (0.0745f plusOrMinus 1e-4f)
cImage.compute(MedianAbsoluteDeviation(channel = ImageChannel.BLUE)) shouldBe (0.0705f plusOrMinus 1e-4f)
}
"color:statistics" {
val cImage = Image.open(NGC3344_COLOR_F32_FITS_PATH.fits())
val cImage = Image.open(NGC3344_COLOR_F32_FITS.fits())

run {
val statistics = cImage.compute(Statistics.RED)
Expand Down
Loading

0 comments on commit 2e18a9f

Please sign in to comment.