diff --git a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVStartRequest.kt b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVStartRequest.kt index 53b99114f..209792636 100644 --- a/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVStartRequest.kt +++ b/api/src/main/kotlin/nebulosa/api/alignment/polar/darv/DARVStartRequest.kt @@ -4,7 +4,7 @@ import nebulosa.api.cameras.CameraStartCaptureRequest import nebulosa.guiding.GuideDirection data class DARVStartRequest( - val capture: CameraStartCaptureRequest = CameraStartCaptureRequest.EMPTY, - val direction: GuideDirection = GuideDirection.NORTH, - val reversed: Boolean = false, + @JvmField val capture: CameraStartCaptureRequest = CameraStartCaptureRequest.EMPTY, + @JvmField val direction: GuideDirection = GuideDirection.NORTH, + @JvmField val reversed: Boolean = false, ) diff --git a/api/src/main/kotlin/nebulosa/api/atlas/BodyPosition.kt b/api/src/main/kotlin/nebulosa/api/atlas/BodyPosition.kt index 64802dd6d..e455723ee 100644 --- a/api/src/main/kotlin/nebulosa/api/atlas/BodyPosition.kt +++ b/api/src/main/kotlin/nebulosa/api/atlas/BodyPosition.kt @@ -15,19 +15,19 @@ import nebulosa.nova.astrometry.Constellation import nebulosa.skycatalog.SkyObject data class BodyPosition( - @field:JsonSerialize(using = RightAscensionSerializer::class) val rightAscensionJ2000: Angle, - @field:JsonSerialize(using = DeclinationSerializer::class) val declinationJ2000: Angle, - @field:JsonSerialize(using = RightAscensionSerializer::class) val rightAscension: Angle, - @field:JsonSerialize(using = DeclinationSerializer::class) val declination: Angle, - @field:JsonSerialize(using = AzimuthSerializer::class) val azimuth: Angle, - @field:JsonSerialize(using = DeclinationSerializer::class) val altitude: Angle, - val magnitude: Double, - val constellation: Constellation, - val distance: Double, - val distanceUnit: String, - val illuminated: Double, - @field:JsonSerialize(using = DegreesSerializer::class) val elongation: Angle, - val leading: Boolean, // true = rises and sets BEFORE Sun. + @field:JsonSerialize(using = RightAscensionSerializer::class) @JvmField val rightAscensionJ2000: Angle, + @field:JsonSerialize(using = DeclinationSerializer::class) @JvmField val declinationJ2000: Angle, + @field:JsonSerialize(using = RightAscensionSerializer::class) @JvmField val rightAscension: Angle, + @field:JsonSerialize(using = DeclinationSerializer::class) @JvmField val declination: Angle, + @field:JsonSerialize(using = AzimuthSerializer::class) @JvmField val azimuth: Angle, + @field:JsonSerialize(using = DeclinationSerializer::class) @JvmField val altitude: Angle, + @JvmField val magnitude: Double, + @JvmField val constellation: Constellation, + @JvmField val distance: Double, + @JvmField val distanceUnit: String, + @JvmField val illuminated: Double, + @field:JsonSerialize(using = DegreesSerializer::class) @JvmField val elongation: Angle, + @JvmField val leading: Boolean, // true = rises and sets BEFORE Sun. ) { companion object { diff --git a/api/src/main/kotlin/nebulosa/api/atlas/CloseApproach.kt b/api/src/main/kotlin/nebulosa/api/atlas/CloseApproach.kt index 06914286a..c011e7c78 100644 --- a/api/src/main/kotlin/nebulosa/api/atlas/CloseApproach.kt +++ b/api/src/main/kotlin/nebulosa/api/atlas/CloseApproach.kt @@ -8,11 +8,11 @@ import java.time.format.DateTimeFormatter import java.util.* data class CloseApproach( - val name: String = "", - val designation: String = "", - val dateTime: Long = 0, - val distance: Double = 0.0, - val absoluteMagnitude: Double = 0.0, + @JvmField val name: String = "", + @JvmField val designation: String = "", + @JvmField val dateTime: Long = 0, + @JvmField val distance: Double = 0.0, + @JvmField val absoluteMagnitude: Double = 0.0, ) { companion object { diff --git a/api/src/main/kotlin/nebulosa/api/atlas/Location.kt b/api/src/main/kotlin/nebulosa/api/atlas/Location.kt index 7d92156e3..8bd3b0ca3 100644 --- a/api/src/main/kotlin/nebulosa/api/atlas/Location.kt +++ b/api/src/main/kotlin/nebulosa/api/atlas/Location.kt @@ -15,7 +15,7 @@ data class Location( @field:JsonSerialize(using = DegreesSerializer::class) @field:JsonDeserialize(using = DegreesDeserializer::class) override val latitude: Angle = 0.0, @field:JsonSerialize(using = DegreesSerializer::class) @field:JsonDeserialize(using = DegreesDeserializer::class) override val longitude: Angle = 0.0, @field:JsonSerialize(using = MetersSerializer::class) @field:JsonDeserialize(using = MetersDeserializer::class) override val elevation: Distance = 0.0, - val offsetInMinutes: Int = 0, + @JvmField val offsetInMinutes: Int = 0, ) : GeographicCoordinate, TimeZonedInSeconds { override val offsetInSeconds = offsetInMinutes * 60 diff --git a/api/src/main/kotlin/nebulosa/api/atlas/MinorPlanet.kt b/api/src/main/kotlin/nebulosa/api/atlas/MinorPlanet.kt index 423a774b0..5d13a502a 100644 --- a/api/src/main/kotlin/nebulosa/api/atlas/MinorPlanet.kt +++ b/api/src/main/kotlin/nebulosa/api/atlas/MinorPlanet.kt @@ -3,20 +3,21 @@ package nebulosa.api.atlas import nebulosa.sbd.SmallBody data class MinorPlanet( - val found: Boolean = false, - val name: String = "", - val spkId: Int = -1, - val kind: SmallBody.BodyKind? = null, - val pha: Boolean = false, val neo: Boolean = false, - val orbitType: String = "", - val parameters: List = emptyList(), - val searchItems: List = emptyList(), + @JvmField val found: Boolean = false, + @JvmField val name: String = "", + @JvmField val spkId: Int = -1, + @JvmField val kind: SmallBody.BodyKind? = null, + @JvmField val pha: Boolean = false, + @JvmField val neo: Boolean = false, + @JvmField val orbitType: String = "", + @JvmField val parameters: List = emptyList(), + @JvmField val searchItems: List = emptyList(), ) { data class OrbitalPhysicalParameter( - val name: String, - val description: String, - val value: String, + @JvmField val name: String, + @JvmField val description: String, + @JvmField val value: String, ) { constructor(param: SmallBody.OrbitElement) : this( @@ -31,8 +32,8 @@ data class MinorPlanet( } data class SearchItem( - val name: String, - val pdes: String, + @JvmField val name: String, + @JvmField val pdes: String, ) companion object { diff --git a/api/src/main/kotlin/nebulosa/api/atlas/SatelliteEntity.kt b/api/src/main/kotlin/nebulosa/api/atlas/SatelliteEntity.kt index bdaaf93bf..bed2ebab2 100644 --- a/api/src/main/kotlin/nebulosa/api/atlas/SatelliteEntity.kt +++ b/api/src/main/kotlin/nebulosa/api/atlas/SatelliteEntity.kt @@ -7,7 +7,7 @@ import nebulosa.api.database.BoxEntity @Entity data class SatelliteEntity( @Id(assignable = true) override var id: Long = 0L, - var name: String = "", - var tle: String = "", - var groups: MutableList = ArrayList(0), + @JvmField var name: String = "", + @JvmField var tle: String = "", + @JvmField var groups: MutableList = ArrayList(0), ) : BoxEntity diff --git a/api/src/main/kotlin/nebulosa/api/atlas/SkyObjectInsideCoordinate.kt b/api/src/main/kotlin/nebulosa/api/atlas/SkyObjectInsideCoordinate.kt index 10b0857bb..ac2e89655 100644 --- a/api/src/main/kotlin/nebulosa/api/atlas/SkyObjectInsideCoordinate.kt +++ b/api/src/main/kotlin/nebulosa/api/atlas/SkyObjectInsideCoordinate.kt @@ -9,9 +9,9 @@ import kotlin.math.cos import kotlin.math.sin data class SkyObjectInsideCoordinate( - private val rightAscension: Angle, - private val declination: Angle, - private val radius: Angle, + @JvmField val rightAscension: Angle, + @JvmField val declination: Angle, + @JvmField val radius: Angle, ) : QueryFilter { private val sinDEC = declination.sin diff --git a/api/src/main/kotlin/nebulosa/api/atlas/Twilight.kt b/api/src/main/kotlin/nebulosa/api/atlas/Twilight.kt index 3414234d5..3ec8cab46 100644 --- a/api/src/main/kotlin/nebulosa/api/atlas/Twilight.kt +++ b/api/src/main/kotlin/nebulosa/api/atlas/Twilight.kt @@ -2,11 +2,11 @@ package nebulosa.api.atlas @Suppress("ArrayInDataClass") data class Twilight( - val civilDusk: DoubleArray, - val nauticalDusk: DoubleArray, - val astronomicalDusk: DoubleArray, - val night: DoubleArray, - val astronomicalDawn: DoubleArray, - val nauticalDawn: DoubleArray, - val civilDawn: DoubleArray, + @JvmField val civilDusk: DoubleArray, + @JvmField val nauticalDusk: DoubleArray, + @JvmField val astronomicalDusk: DoubleArray, + @JvmField val night: DoubleArray, + @JvmField val astronomicalDawn: DoubleArray, + @JvmField val nauticalDawn: DoubleArray, + @JvmField val civilDawn: DoubleArray, ) diff --git a/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameEntity.kt b/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameEntity.kt index 631dc5b9e..1b15e657e 100644 --- a/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameEntity.kt +++ b/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameEntity.kt @@ -13,16 +13,16 @@ import java.nio.file.Path @Entity data class CalibrationFrameEntity( @Id override var id: Long = 0L, - @Index @Convert(converter = FrameTypePropertyConverter::class, dbType = Int::class) var type: FrameType = FrameType.LIGHT, - @Index var name: String = "", - var filter: String? = null, - var exposureTime: Long = 0L, - var temperature: Double = 0.0, - var width: Int = 0, - var height: Int = 0, - var binX: Int = 0, - var binY: Int = 0, - var gain: Double = 0.0, - @Convert(converter = PathPropertyConverter::class, dbType = String::class) var path: Path? = null, - var enabled: Boolean = true, + @JvmField @Index @Convert(converter = FrameTypePropertyConverter::class, dbType = Int::class) var type: FrameType = FrameType.LIGHT, + @JvmField @Index var name: String = "", + @JvmField var filter: String? = null, + @JvmField var exposureTime: Long = 0L, + @JvmField var temperature: Double = 0.0, + @JvmField var width: Int = 0, + @JvmField var height: Int = 0, + @JvmField var binX: Int = 0, + @JvmField var binY: Int = 0, + @JvmField var gain: Double = 0.0, + @JvmField @Convert(converter = PathPropertyConverter::class, dbType = String::class) var path: Path? = null, + @JvmField var enabled: Boolean = true, ) : BoxEntity diff --git a/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameGroup.kt b/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameGroup.kt index 7e4171cda..df469a2cd 100644 --- a/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameGroup.kt +++ b/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameGroup.kt @@ -1,8 +1,8 @@ package nebulosa.api.calibration data class CalibrationFrameGroup( - val id: Int, - val name: String, - val key: CalibrationGroupKey, - val frames: List, + @JvmField val id: Int, + @JvmField val name: String, + @JvmField val key: CalibrationGroupKey, + @JvmField val frames: List, ) diff --git a/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameService.kt b/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameService.kt index 4dc82f92c..5939b03d3 100644 --- a/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameService.kt +++ b/api/src/main/kotlin/nebulosa/api/calibration/CalibrationFrameService.kt @@ -6,8 +6,8 @@ import nebulosa.image.algorithms.transformation.correction.BiasSubtraction import nebulosa.image.algorithms.transformation.correction.DarkSubtraction import nebulosa.image.algorithms.transformation.correction.FlatCorrection import nebulosa.image.format.ImageHdu -import nebulosa.image.format.ReadableHeader import nebulosa.indi.device.camera.FrameType +import nebulosa.indi.device.camera.FrameType.Companion.frameType import nebulosa.log.loggerFor import nebulosa.xisf.isXisf import nebulosa.xisf.xisf @@ -100,7 +100,7 @@ class CalibrationFrameService( } fun upload(name: String, path: Path): List { - val files = if (path.isRegularFile() && path.isFits) listOf(path) + val files = if (path.isRegularFile()) listOf(path) else if (path.isDirectory()) path.listDirectoryEntries("*.{fits,fit,xisf}").filter { it.isRegularFile() } else return emptyList() @@ -220,17 +220,5 @@ class CalibrationFrameService( companion object { @JvmStatic private val LOG = loggerFor() - - @JvmStatic val ReadableHeader.frameType - get() = frame?.let { - if (it.contains("LIGHT", true)) FrameType.LIGHT - else if (it.contains("DARK", true)) FrameType.DARK - else if (it.contains("FLAT", true)) FrameType.FLAT - else if (it.contains("BIAS", true)) FrameType.BIAS - else null - } - - inline val Path.isFits - get() = "$this".let { it.endsWith(".fits") || it.endsWith(".fit") } } } diff --git a/api/src/main/kotlin/nebulosa/api/calibration/CalibrationGroupKey.kt b/api/src/main/kotlin/nebulosa/api/calibration/CalibrationGroupKey.kt index dd659e464..5f63971b4 100644 --- a/api/src/main/kotlin/nebulosa/api/calibration/CalibrationGroupKey.kt +++ b/api/src/main/kotlin/nebulosa/api/calibration/CalibrationGroupKey.kt @@ -4,11 +4,15 @@ import nebulosa.indi.device.camera.FrameType import kotlin.math.roundToInt data class CalibrationGroupKey( - val type: FrameType, val filter: String?, - val width: Int, val height: Int, - val binX: Int, val binY: Int, - val exposureTime: Long, - val temperature: Int, val gain: Double, + @JvmField val type: FrameType, + @JvmField val filter: String?, + @JvmField val width: Int, + @JvmField val height: Int, + @JvmField val binX: Int, + @JvmField val binY: Int, + @JvmField val exposureTime: Long, + @JvmField val temperature: Int, + @JvmField val gain: Double, ) { companion object { diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureNamingFormatter.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureNamingFormatter.kt index 6b8a8a0a7..ab590b6f0 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureNamingFormatter.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureNamingFormatter.kt @@ -1,11 +1,11 @@ package nebulosa.api.cameras -import nebulosa.api.calibration.CalibrationFrameService.Companion.frameType import nebulosa.common.concurrency.atomic.Incrementer import nebulosa.fits.* import nebulosa.image.format.ReadableHeader import nebulosa.indi.device.camera.Camera import nebulosa.indi.device.camera.FrameType +import nebulosa.indi.device.camera.FrameType.Companion.frameType import nebulosa.indi.device.filterwheel.FilterWheel import nebulosa.indi.device.focuser.Focuser import nebulosa.indi.device.mount.Mount diff --git a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureTask.kt b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureTask.kt index bd8dad2db..c57a709c7 100644 --- a/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureTask.kt +++ b/api/src/main/kotlin/nebulosa/api/cameras/CameraCaptureTask.kt @@ -32,6 +32,7 @@ import java.util.concurrent.Executor import java.util.concurrent.atomic.AtomicBoolean import kotlin.io.path.copyTo import kotlin.io.path.exists +import kotlin.io.path.extension data class CameraCaptureTask( @JvmField val camera: Camera, @@ -100,7 +101,7 @@ data class CameraCaptureTask( private fun LiveStackingRequest.processCalibrationGroup(): LiveStackingRequest { return if (calibrationFrameProvider != null && enabled && - !request.calibrationGroup.isNullOrBlank() && (dark == null || flat == null || bias == null) + !request.calibrationGroup.isNullOrBlank() && (darkPath == null || flatPath == null || biasPath == null) ) { val calibrationGroup = request.calibrationGroup val temperature = camera.temperature @@ -119,24 +120,27 @@ data class CameraCaptureTask( calibrationGroup, temperature, binX, binY, width, height, exposureTime, gain, filter ) - val newDark = dark?.takeIf { it.exists() } ?: calibrationFrameProvider + val newDarkPath = darkPath?.takeIf { it.exists() } ?: calibrationFrameProvider .findBestDarkFrames(calibrationGroup, temperature, width, height, binX, binY, exposureTime, gain) .firstOrNull() ?.path - val newFlat = flat?.takeIf { it.exists() } ?: calibrationFrameProvider + val newFlatPath = flatPath?.takeIf { it.exists() } ?: calibrationFrameProvider .findBestFlatFrames(calibrationGroup, width, height, binX, binY, filter) .firstOrNull() ?.path - val newBias = if (newDark != null) null else bias?.takeIf { it.exists() } ?: calibrationFrameProvider + val newBiasPath = if (newDarkPath != null) null else biasPath?.takeIf { it.exists() } ?: calibrationFrameProvider .findBestBiasFrames(calibrationGroup, width, height, binX, binY) .firstOrNull() ?.path - LOG.info("live stacking will use calibration frames. group={}, dark={}, flat={}, bias={}", calibrationGroup, newDark, newFlat, newBias) + LOG.info( + "live stacking will use calibration frames. group={}, dark={}, flat={}, bias={}", + calibrationGroup, newDarkPath, newFlatPath, newBiasPath + ) - copy(dark = newDark, flat = newFlat, bias = newBias) + copy(darkPath = newDarkPath, flatPath = newFlatPath, biasPath = newBiasPath) } else { this } @@ -334,7 +338,7 @@ data class CameraCaptureTask( sendEvent(CameraCaptureState.STACKING) liveStacker!!.add(path)?.let { - val stackedPath = Path.of("${path.parent}", "STACKED.fits") + val stackedPath = Path.of("${path.parent}", "STACKED.${it.extension}") it.copyTo(stackedPath, true) stackedPath } diff --git a/api/src/main/kotlin/nebulosa/api/connection/ConnectionStatus.kt b/api/src/main/kotlin/nebulosa/api/connection/ConnectionStatus.kt index 2eba3f9ae..f511fda92 100644 --- a/api/src/main/kotlin/nebulosa/api/connection/ConnectionStatus.kt +++ b/api/src/main/kotlin/nebulosa/api/connection/ConnectionStatus.kt @@ -1,8 +1,9 @@ package nebulosa.api.connection data class ConnectionStatus( - val id: String, - val type: ConnectionType, - val host: String, val port: Int, - val ip: String? = null, + @JvmField val id: String, + @JvmField val type: ConnectionType, + @JvmField val host: String, + @JvmField val port: Int, + @JvmField val ip: String? = null, ) diff --git a/api/src/main/kotlin/nebulosa/api/guiding/GuiderInfo.kt b/api/src/main/kotlin/nebulosa/api/guiding/GuiderInfo.kt index 3ce34f5b7..ec1d38a4f 100644 --- a/api/src/main/kotlin/nebulosa/api/guiding/GuiderInfo.kt +++ b/api/src/main/kotlin/nebulosa/api/guiding/GuiderInfo.kt @@ -3,10 +3,10 @@ package nebulosa.api.guiding import nebulosa.guiding.GuideState data class GuiderInfo( - val connected: Boolean = false, - val state: GuideState = GuideState.STOPPED, - val settling: Boolean = false, - val pixelScale: Double = 1.0, + @JvmField val connected: Boolean = false, + @JvmField val state: GuideState = GuideState.STOPPED, + @JvmField val settling: Boolean = false, + @JvmField val pixelScale: Double = 1.0, ) { companion object { diff --git a/api/src/main/kotlin/nebulosa/api/guiding/GuiderMessageEvent.kt b/api/src/main/kotlin/nebulosa/api/guiding/GuiderMessageEvent.kt index 33ed3a7bc..8237710b5 100644 --- a/api/src/main/kotlin/nebulosa/api/guiding/GuiderMessageEvent.kt +++ b/api/src/main/kotlin/nebulosa/api/guiding/GuiderMessageEvent.kt @@ -2,4 +2,4 @@ package nebulosa.api.guiding import nebulosa.api.message.MessageEvent -data class GuiderMessageEvent(override val eventName: String, val data: Any? = null) : MessageEvent +data class GuiderMessageEvent(override val eventName: String, @JvmField val data: Any? = null) : MessageEvent diff --git a/api/src/main/kotlin/nebulosa/api/guiding/HistoryStep.kt b/api/src/main/kotlin/nebulosa/api/guiding/HistoryStep.kt index a4f178d70..61f22abcd 100644 --- a/api/src/main/kotlin/nebulosa/api/guiding/HistoryStep.kt +++ b/api/src/main/kotlin/nebulosa/api/guiding/HistoryStep.kt @@ -3,11 +3,11 @@ package nebulosa.api.guiding import nebulosa.guiding.GuideStep data class HistoryStep( - val id: Long = 0L, - val rmsRA: Double = 0.0, - val rmsDEC: Double = 0.0, - val rmsTotal: Double = 0.0, - val guideStep: GuideStep? = null, - val ditherX: Double = 0.0, - val ditherY: Double = 0.0, + @JvmField val id: Long = 0L, + @JvmField val rmsRA: Double = 0.0, + @JvmField val rmsDEC: Double = 0.0, + @JvmField val rmsTotal: Double = 0.0, + @JvmField val guideStep: GuideStep? = null, + @JvmField val ditherX: Double = 0.0, + @JvmField val ditherY: Double = 0.0, ) diff --git a/api/src/main/kotlin/nebulosa/api/guiding/SettleInfo.kt b/api/src/main/kotlin/nebulosa/api/guiding/SettleInfo.kt index f46d6986d..5ec2eda2b 100644 --- a/api/src/main/kotlin/nebulosa/api/guiding/SettleInfo.kt +++ b/api/src/main/kotlin/nebulosa/api/guiding/SettleInfo.kt @@ -4,9 +4,9 @@ import nebulosa.guiding.Guider import org.hibernate.validator.constraints.Range data class SettleInfo( - @Range(min = 1, max = 25) val amount: Double = 1.5, - @Range(min = 1, max = 60) val time: Long = 10, - @Range(min = 1, max = 60) val timeout: Long = 30, + @Range(min = 1, max = 25) @JvmField val amount: Double = 1.5, + @Range(min = 1, max = 60) @JvmField val time: Long = 10, + @Range(min = 1, max = 60) @JvmField val timeout: Long = 30, ) { companion object { diff --git a/api/src/main/kotlin/nebulosa/api/image/CoordinateInterpolation.kt b/api/src/main/kotlin/nebulosa/api/image/CoordinateInterpolation.kt index 5fa45e25a..563c7723f 100644 --- a/api/src/main/kotlin/nebulosa/api/image/CoordinateInterpolation.kt +++ b/api/src/main/kotlin/nebulosa/api/image/CoordinateInterpolation.kt @@ -4,8 +4,12 @@ import java.time.LocalDateTime @Suppress("ArrayInDataClass") data class CoordinateInterpolation( - val ma: DoubleArray, - val md: DoubleArray, - val x0: Int, val y0: Int, val x1: Int, val y1: Int, - val delta: Int, val date: LocalDateTime?, + @JvmField val ma: DoubleArray, + @JvmField val md: DoubleArray, + @JvmField val x0: Int, + @JvmField val y0: Int, + @JvmField val x1: Int, + @JvmField val y1: Int, + @JvmField val delta: Int, + @JvmField val date: LocalDateTime?, ) diff --git a/api/src/main/kotlin/nebulosa/api/image/ImageAnnotation.kt b/api/src/main/kotlin/nebulosa/api/image/ImageAnnotation.kt index 5253b0038..1db713fd5 100644 --- a/api/src/main/kotlin/nebulosa/api/image/ImageAnnotation.kt +++ b/api/src/main/kotlin/nebulosa/api/image/ImageAnnotation.kt @@ -15,9 +15,9 @@ import nebulosa.skycatalog.SkyObjectType data class ImageAnnotation( override val x: Double, override val y: Double, - val star: StarDSO? = null, - val dso: StarDSO? = null, - val minorPlanet: MinorPlanet? = null, + @JvmField val star: StarDSO? = null, + @JvmField val dso: StarDSO? = null, + @JvmField val minorPlanet: MinorPlanet? = null, ) : Point2D { data class StarDSO( @@ -48,6 +48,6 @@ data class ImageAnnotation( @field:JsonSerialize(using = RightAscensionSerializer::class) override val rightAscensionJ2000: Angle = 0.0, @field:JsonSerialize(using = DeclinationSerializer::class) override val declinationJ2000: Angle = 0.0, override val magnitude: Double = SkyObject.UNKNOWN_MAGNITUDE, - val constellation: Constellation = Constellation.find(ICRF.equatorial(rightAscensionJ2000, declinationJ2000)), + @JvmField val constellation: Constellation = Constellation.find(ICRF.equatorial(rightAscensionJ2000, declinationJ2000)), ) : SkyObject } diff --git a/api/src/main/kotlin/nebulosa/api/image/ImageHeaderItem.kt b/api/src/main/kotlin/nebulosa/api/image/ImageHeaderItem.kt index faf357950..b02f3a15d 100644 --- a/api/src/main/kotlin/nebulosa/api/image/ImageHeaderItem.kt +++ b/api/src/main/kotlin/nebulosa/api/image/ImageHeaderItem.kt @@ -1,3 +1,3 @@ package nebulosa.api.image -data class ImageHeaderItem(val name: String, val value: String) +data class ImageHeaderItem(@JvmField val name: String, @JvmField val value: String) diff --git a/api/src/main/kotlin/nebulosa/api/image/ImageInfo.kt b/api/src/main/kotlin/nebulosa/api/image/ImageInfo.kt index f9afc085d..9c41c8d78 100644 --- a/api/src/main/kotlin/nebulosa/api/image/ImageInfo.kt +++ b/api/src/main/kotlin/nebulosa/api/image/ImageInfo.kt @@ -10,14 +10,18 @@ import nebulosa.indi.device.camera.Camera import java.nio.file.Path data class ImageInfo( - val path: Path, - val width: Int, val height: Int, val mono: Boolean, - val stretchShadow: Float = 0.0f, val stretchHighlight: Float = 1.0f, val stretchMidtone: Float = 0.5f, - @field:JsonSerialize(using = RightAscensionSerializer::class) val rightAscension: Double? = null, - @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, + @JvmField val path: Path, + @JvmField val width: Int, + @JvmField val height: Int, + @JvmField val mono: Boolean, + @JvmField val stretchShadow: Float = 0.0f, + @JvmField val stretchHighlight: Float = 1.0f, + @JvmField val stretchMidtone: Float = 0.5f, + @field:JsonSerialize(using = RightAscensionSerializer::class) @JvmField val rightAscension: Double? = null, + @field:JsonSerialize(using = DeclinationSerializer::class) @JvmField val declination: Double? = null, + @JvmField val solved: ImageSolved? = null, + @JvmField val headers: List = emptyList(), + @JvmField val bitpix: Bitpix = Bitpix.BYTE, + @JvmField val camera: Camera? = null, + @JsonIgnoreProperties("histogram") @JvmField val statistics: Statistics.Data? = null, ) diff --git a/api/src/main/kotlin/nebulosa/api/image/ImageSolved.kt b/api/src/main/kotlin/nebulosa/api/image/ImageSolved.kt index 5823fffc0..04202b45f 100644 --- a/api/src/main/kotlin/nebulosa/api/image/ImageSolved.kt +++ b/api/src/main/kotlin/nebulosa/api/image/ImageSolved.kt @@ -4,14 +4,14 @@ import nebulosa.math.* import nebulosa.platesolver.PlateSolution data class ImageSolved( - val solved: Boolean = false, - val orientation: Double = 0.0, - val scale: Double = 0.0, - val rightAscensionJ2000: String = "", - val declinationJ2000: String = "", - val width: Double = 0.0, - val height: Double = 0.0, - val radius: Double = 0.0, + @JvmField val solved: Boolean = false, + @JvmField val orientation: Double = 0.0, + @JvmField val scale: Double = 0.0, + @JvmField val rightAscensionJ2000: String = "", + @JvmField val declinationJ2000: String = "", + @JvmField val width: Double = 0.0, + @JvmField val height: Double = 0.0, + @JvmField val radius: Double = 0.0, ) { constructor(solution: PlateSolution) : this( diff --git a/api/src/main/kotlin/nebulosa/api/image/SaveImage.kt b/api/src/main/kotlin/nebulosa/api/image/SaveImage.kt index 1bfa717f4..2c551086f 100644 --- a/api/src/main/kotlin/nebulosa/api/image/SaveImage.kt +++ b/api/src/main/kotlin/nebulosa/api/image/SaveImage.kt @@ -4,9 +4,9 @@ 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, + @JvmField val format: ImageExtension = ImageExtension.FITS, + @JvmField val bitpix: Bitpix = Bitpix.BYTE, + @JvmField val shouldBeTransformed: Boolean = true, + @JvmField val transformation: ImageTransformation = ImageTransformation.EMPTY, + @JvmField val path: Path? = null, ) diff --git a/api/src/main/kotlin/nebulosa/api/indi/INDIMessageEvent.kt b/api/src/main/kotlin/nebulosa/api/indi/INDIMessageEvent.kt index 06c49459b..19b320255 100644 --- a/api/src/main/kotlin/nebulosa/api/indi/INDIMessageEvent.kt +++ b/api/src/main/kotlin/nebulosa/api/indi/INDIMessageEvent.kt @@ -9,8 +9,8 @@ import nebulosa.indi.device.PropertyVector data class INDIMessageEvent( override val eventName: String, override val device: Device? = null, - val property: PropertyVector<*, *>? = null, - val message: String? = null, + @JvmField val property: PropertyVector<*, *>? = null, + @JvmField val message: String? = null, ) : DeviceMessageEvent { constructor(eventName: String, event: DevicePropertyEvent) : this(eventName, event.device, property = event.property) diff --git a/api/src/main/kotlin/nebulosa/api/indi/INDISendProperty.kt b/api/src/main/kotlin/nebulosa/api/indi/INDISendProperty.kt index 9e71146b8..3bd06d872 100644 --- a/api/src/main/kotlin/nebulosa/api/indi/INDISendProperty.kt +++ b/api/src/main/kotlin/nebulosa/api/indi/INDISendProperty.kt @@ -7,7 +7,7 @@ import jakarta.validation.constraints.NotNull import nebulosa.indi.protocol.PropertyType data class INDISendProperty( - @field:NotBlank val name: String = "", - @field:NotNull val type: PropertyType = PropertyType.SWITCH, - @field:NotEmpty @field:Valid val items: List = emptyList(), + @field:NotBlank @JvmField val name: String = "", + @field:NotNull @JvmField val type: PropertyType = PropertyType.SWITCH, + @field:NotEmpty @field:Valid @JvmField val items: List = emptyList(), ) diff --git a/api/src/main/kotlin/nebulosa/api/indi/INDISendPropertyItem.kt b/api/src/main/kotlin/nebulosa/api/indi/INDISendPropertyItem.kt index 8b506a378..c6d382497 100644 --- a/api/src/main/kotlin/nebulosa/api/indi/INDISendPropertyItem.kt +++ b/api/src/main/kotlin/nebulosa/api/indi/INDISendPropertyItem.kt @@ -4,6 +4,6 @@ import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.NotNull data class INDISendPropertyItem( - @field:NotBlank val name: String = "", - @field:NotNull val value: Any = "", + @field:NotBlank @JvmField val name: String = "", + @field:NotNull @JvmField val value: Any = "", ) diff --git a/api/src/main/kotlin/nebulosa/api/livestacker/LiveStackingRequest.kt b/api/src/main/kotlin/nebulosa/api/livestacker/LiveStackingRequest.kt index 6f3cd31db..1d9a3bb47 100644 --- a/api/src/main/kotlin/nebulosa/api/livestacker/LiveStackingRequest.kt +++ b/api/src/main/kotlin/nebulosa/api/livestacker/LiveStackingRequest.kt @@ -1,12 +1,10 @@ package nebulosa.api.livestacker +import jakarta.validation.constraints.NotNull import nebulosa.livestacker.LiveStacker import nebulosa.pixinsight.livestacker.PixInsightLiveStacker -import nebulosa.pixinsight.script.PixInsightIsRunning -import nebulosa.pixinsight.script.PixInsightScriptRunner -import nebulosa.pixinsight.script.PixInsightStartup +import nebulosa.pixinsight.script.startPixInsight import nebulosa.siril.livestacker.SirilLiveStacker -import org.jetbrains.annotations.NotNull import java.nio.file.Files import java.nio.file.Path import java.util.function.Supplier @@ -15,9 +13,9 @@ data class LiveStackingRequest( @JvmField val enabled: Boolean = false, @JvmField val type: LiveStackerType = LiveStackerType.SIRIL, @JvmField @field:NotNull val executablePath: Path? = null, - @JvmField val dark: Path? = null, - @JvmField val flat: Path? = null, - @JvmField val bias: Path? = null, + @JvmField val darkPath: Path? = null, + @JvmField val flatPath: Path? = null, + @JvmField val biasPath: Path? = null, @JvmField val use32Bits: Boolean = false, @JvmField val slot: Int = 1, ) : Supplier { @@ -26,17 +24,10 @@ data class LiveStackingRequest( val workingDirectory = Files.createTempDirectory("ls-") return when (type) { - LiveStackerType.SIRIL -> SirilLiveStacker(executablePath!!, workingDirectory, dark, flat, use32Bits) + LiveStackerType.SIRIL -> SirilLiveStacker(executablePath!!, workingDirectory, darkPath, flatPath, use32Bits) LiveStackerType.PIXINSIGHT -> { - val runner = PixInsightScriptRunner(executablePath!!) - - if (!PixInsightIsRunning(slot).use { it.runSync(runner) }) { - if (!PixInsightStartup(slot).use { it.runSync(runner) }) { - throw IllegalStateException("unable to start PixInsight") - } - } - - PixInsightLiveStacker(runner, workingDirectory, dark, flat, bias, use32Bits, slot) + val runner = startPixInsight(executablePath!!, slot) + PixInsightLiveStacker(runner, workingDirectory, darkPath, flatPath, biasPath, use32Bits, slot) } } } diff --git a/api/src/main/kotlin/nebulosa/api/mounts/ComputedLocation.kt b/api/src/main/kotlin/nebulosa/api/mounts/ComputedLocation.kt index f825170f9..497350194 100644 --- a/api/src/main/kotlin/nebulosa/api/mounts/ComputedLocation.kt +++ b/api/src/main/kotlin/nebulosa/api/mounts/ComputedLocation.kt @@ -12,15 +12,15 @@ import nebulosa.nova.astrometry.Constellation import java.time.LocalDateTime data class ComputedLocation( - @field:JsonSerialize(using = RightAscensionSerializer::class) var rightAscension: Angle = 0.0, - @field:JsonSerialize(using = DeclinationSerializer::class) var declination: Angle = 0.0, - @field:JsonSerialize(using = RightAscensionSerializer::class) var rightAscensionJ2000: Angle = 0.0, - @field:JsonSerialize(using = DeclinationSerializer::class) var declinationJ2000: Angle = 0.0, - @field:JsonSerialize(using = AzimuthSerializer::class) var azimuth: Angle = 0.0, - @field:JsonSerialize(using = DeclinationSerializer::class) var altitude: Angle = 0.0, - var constellation: Constellation = Constellation.AND, - @field:JsonSerialize(using = LSTSerializer::class) var lst: Angle = 0.0, - @field:JsonFormat(pattern = "HH:mm") var meridianAt: LocalDateTime = LocalDateTime.MIN, - @field:JsonSerialize(using = LSTSerializer::class) var timeLeftToMeridianFlip: Angle = 0.0, - var pierSide: PierSide = PierSide.NEITHER, + @field:JsonSerialize(using = RightAscensionSerializer::class) @JvmField var rightAscension: Angle = 0.0, + @field:JsonSerialize(using = DeclinationSerializer::class) @JvmField var declination: Angle = 0.0, + @field:JsonSerialize(using = RightAscensionSerializer::class) @JvmField var rightAscensionJ2000: Angle = 0.0, + @field:JsonSerialize(using = DeclinationSerializer::class) @JvmField var declinationJ2000: Angle = 0.0, + @field:JsonSerialize(using = AzimuthSerializer::class) @JvmField var azimuth: Angle = 0.0, + @field:JsonSerialize(using = DeclinationSerializer::class) @JvmField var altitude: Angle = 0.0, + @JvmField var constellation: Constellation = Constellation.AND, + @field:JsonSerialize(using = LSTSerializer::class) @JvmField var lst: Angle = 0.0, + @field:JsonFormat(pattern = "HH:mm") @JvmField var meridianAt: LocalDateTime = LocalDateTime.MIN, + @field:JsonSerialize(using = LSTSerializer::class) @JvmField var timeLeftToMeridianFlip: Angle = 0.0, + @JvmField var pierSide: PierSide = PierSide.NEITHER, ) diff --git a/api/src/main/kotlin/nebulosa/api/preference/PreferenceEntity.kt b/api/src/main/kotlin/nebulosa/api/preference/PreferenceEntity.kt index b00b5d7f2..4364d1baa 100644 --- a/api/src/main/kotlin/nebulosa/api/preference/PreferenceEntity.kt +++ b/api/src/main/kotlin/nebulosa/api/preference/PreferenceEntity.kt @@ -9,6 +9,6 @@ import nebulosa.api.database.BoxEntity @Entity data class PreferenceEntity( @Id override var id: Long = 0L, - @Unique(onConflict = ConflictStrategy.REPLACE) var key: String = "", - var value: String? = null, + @Unique(onConflict = ConflictStrategy.REPLACE) @JvmField var key: String = "", + @JvmField var value: String? = null, ) : BoxEntity diff --git a/api/src/main/kotlin/nebulosa/api/preference/PreferenceRequestBody.kt b/api/src/main/kotlin/nebulosa/api/preference/PreferenceRequestBody.kt deleted file mode 100644 index 00e0448f2..000000000 --- a/api/src/main/kotlin/nebulosa/api/preference/PreferenceRequestBody.kt +++ /dev/null @@ -1,3 +0,0 @@ -package nebulosa.api.preference - -data class PreferenceRequestBody(val data: Any?) diff --git a/api/src/main/kotlin/nebulosa/api/stacker/AnalyzedTarget.kt b/api/src/main/kotlin/nebulosa/api/stacker/AnalyzedTarget.kt new file mode 100644 index 000000000..892ff518b --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/stacker/AnalyzedTarget.kt @@ -0,0 +1,23 @@ +package nebulosa.api.stacker + +import nebulosa.fits.* +import nebulosa.image.format.ReadableHeader +import nebulosa.indi.device.camera.FrameType +import nebulosa.indi.device.camera.FrameType.Companion.frameType + +data class AnalyzedTarget( + @JvmField val width: Int, + @JvmField val height: Int, + @JvmField val binX: Int, + @JvmField val binY: Int, + @JvmField val gain: Double, + @JvmField val exposureTime: Long, + @JvmField val type: FrameType, + @JvmField val group: StackerGroupType, +) { + + constructor(header: ReadableHeader) : this( + header.width, header.height, header.binX, header.binY, header.gain, header.exposureTimeInMicroseconds, + header.frameType ?: FrameType.LIGHT, StackerGroupType.from(header) + ) +} diff --git a/api/src/main/kotlin/nebulosa/api/stacker/StackerController.kt b/api/src/main/kotlin/nebulosa/api/stacker/StackerController.kt new file mode 100644 index 000000000..66a95ff33 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/stacker/StackerController.kt @@ -0,0 +1,46 @@ +package nebulosa.api.stacker + +import jakarta.validation.Valid +import nebulosa.common.concurrency.cancel.CancellationToken +import org.springframework.validation.annotation.Validated +import org.springframework.web.bind.annotation.* +import java.nio.file.Path +import java.util.concurrent.atomic.AtomicReference + +@Validated +@RestController +@RequestMapping("stacker") +class StackerController( + private val stackerService: StackerService, +) { + + private val cancellationToken = AtomicReference() + + @PutMapping("start") + fun start(@RequestBody @Valid body: StackingRequest): Path? { + return if (cancellationToken.compareAndSet(null, CancellationToken())) { + try { + stackerService.stack(body, cancellationToken.get()) + } finally { + cancellationToken.getAndSet(null)?.unlistenAll() + } + } else { + null + } + } + + @GetMapping("running") + fun isRunning(): Boolean { + return cancellationToken.get() != null + } + + @PutMapping("stop") + fun stop() { + cancellationToken.get()?.cancel() + } + + @PutMapping("analyze") + fun analyze(@RequestParam path: Path): AnalyzedTarget? { + return stackerService.analyze(path) + } +} diff --git a/api/src/main/kotlin/nebulosa/api/stacker/StackerGroupType.kt b/api/src/main/kotlin/nebulosa/api/stacker/StackerGroupType.kt new file mode 100644 index 000000000..641984102 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/stacker/StackerGroupType.kt @@ -0,0 +1,25 @@ +package nebulosa.api.stacker + +import nebulosa.fits.filter +import nebulosa.image.format.ReadableHeader + +enum class StackerGroupType { + LUMINANCE, + RED, + GREEN, + BLUE, + MONO, + RGB; + + companion object { + + @JvmStatic + fun from(header: ReadableHeader) = header.filter?.let { + if (it.contains("RED", true) || it.equals("R", true)) RED + else if (it.contains("GREEN", true) || it.equals("G", true)) GREEN + else if (it.contains("BLUE", true) || it.equals("B", true)) BLUE + else if (it.contains("LUMINANCE", true) || it.equals("L", true)) LUMINANCE + else MONO + } ?: MONO + } +} diff --git a/api/src/main/kotlin/nebulosa/api/stacker/StackerService.kt b/api/src/main/kotlin/nebulosa/api/stacker/StackerService.kt new file mode 100644 index 000000000..f56ababd2 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/stacker/StackerService.kt @@ -0,0 +1,113 @@ +package nebulosa.api.stacker + +import nebulosa.common.concurrency.cancel.CancellationToken +import nebulosa.fits.fits +import nebulosa.fits.isFits +import nebulosa.stacker.AutoStacker +import nebulosa.xisf.isXisf +import nebulosa.xisf.xisf +import org.springframework.stereotype.Service +import java.nio.file.Path +import kotlin.io.path.exists +import kotlin.io.path.isDirectory +import kotlin.io.path.isRegularFile + +@Service +class StackerService { + + fun stack(request: StackingRequest, cancellationToken: CancellationToken = CancellationToken.NONE): Path? { + require(request.outputDirectory != null && request.outputDirectory.exists() && request.outputDirectory.isDirectory()) + + val luminance = request.targets.filter { it.enabled && it.group == StackerGroupType.LUMINANCE } + val red = request.targets.filter { it.enabled && it.group == StackerGroupType.RED } + val green = request.targets.filter { it.enabled && it.group == StackerGroupType.GREEN } + val blue = request.targets.filter { it.enabled && it.group == StackerGroupType.BLUE } + val mono = request.targets.filter { it.enabled && it.group == StackerGroupType.MONO } + val rgb = request.targets.filter { it.enabled && it.group == StackerGroupType.RGB } + + val name = "${System.currentTimeMillis()}" + + // Combined LRGB + return if (luminance.size + red.size + green.size + blue.size > 1) { + val stacker = request.get() + + cancellationToken.listen { stacker.stop() } + + val stackedLuminancePath = luminance.stack(request, stacker, name, StackerGroupType.LUMINANCE, cancellationToken) + val stackedRedPath = red.stack(request, stacker, name, StackerGroupType.RED, cancellationToken) + val stackedGreenPath = green.stack(request, stacker, name, StackerGroupType.GREEN, cancellationToken) + val stackedBluePath = blue.stack(request, stacker, name, StackerGroupType.BLUE, cancellationToken) + + if (cancellationToken.isCancelled) { + null + } else { + val combinedPath = Path.of("${request.outputDirectory}", "$name.LRGB.fits") + stacker.combineLRGB(combinedPath, stackedLuminancePath, stackedRedPath, stackedGreenPath, stackedBluePath) + combinedPath + } + } + // LRGB + else if (rgb.size > 1 || luminance.size + rgb.size > 1) { + val stacker = request.get() + + val stackedLuminancePath = luminance.stack(request, stacker, name, StackerGroupType.LUMINANCE, cancellationToken) + val stackedRGBPath = rgb.stack(request, stacker, name, StackerGroupType.RGB, cancellationToken) + + if (cancellationToken.isCancelled) { + null + } else if (stackedLuminancePath != null && stackedRGBPath != null) { + val combinedPath = Path.of("${request.outputDirectory}", "$name.LRGB.fits") + stacker.combineLuminance(combinedPath, stackedLuminancePath, stackedRGBPath, false) + combinedPath + } else { + stackedLuminancePath ?: stackedRGBPath + } + } + // MONO + else if (mono.size > 1 || luminance.size + mono.size > 1) { + val stacker = request.get() + + val stackedLuminancePath = luminance.stack(request, stacker, name, StackerGroupType.LUMINANCE, cancellationToken) + val stackedMonoPath = mono.stack(request, stacker, name, StackerGroupType.MONO, cancellationToken) + + if (cancellationToken.isCancelled) { + null + } else if (stackedLuminancePath != null && stackedMonoPath != null) { + val combinedPath = Path.of("${request.outputDirectory}", "$name.LRGB.fits") + stacker.combineLuminance(combinedPath, stackedLuminancePath, stackedMonoPath, true) + combinedPath + } else { + stackedLuminancePath ?: stackedMonoPath + } + } else { + null + } + } + + private fun List.stack( + request: StackingRequest, stacker: AutoStacker, + name: String, group: StackerGroupType, cancellationToken: CancellationToken, + ): Path? { + return if (cancellationToken.isCancelled) { + null + } else if (size > 1) { + val outputPath = Path.of("${request.outputDirectory}", "$name.$group.fits") + if (stacker.stack(map { it.path!! }, outputPath, request.referencePath!!)) outputPath else null + } else if (isNotEmpty()) { + val outputPath = Path.of("${request.outputDirectory}", "$name.$group.fits") + if (stacker.align(request.referencePath!!, this[0].path!!, outputPath)) outputPath else null + } else { + null + } + } + + fun analyze(path: Path): AnalyzedTarget? { + if (!path.exists() || !path.isRegularFile()) return null + + val image = if (path.isFits()) path.fits() + else if (path.isXisf()) path.xisf() + else return null + + return image.use { it.firstOrNull()?.header }?.let(::AnalyzedTarget) + } +} diff --git a/api/src/main/kotlin/nebulosa/api/stacker/StackerType.kt b/api/src/main/kotlin/nebulosa/api/stacker/StackerType.kt new file mode 100644 index 000000000..4aee6a289 --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/stacker/StackerType.kt @@ -0,0 +1,5 @@ +package nebulosa.api.stacker + +enum class StackerType { + PIXINSIGHT, +} diff --git a/api/src/main/kotlin/nebulosa/api/stacker/StackingRequest.kt b/api/src/main/kotlin/nebulosa/api/stacker/StackingRequest.kt new file mode 100644 index 000000000..66b00312a --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/stacker/StackingRequest.kt @@ -0,0 +1,45 @@ +package nebulosa.api.stacker + +import jakarta.validation.Valid +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size +import nebulosa.pixinsight.script.startPixInsight +import nebulosa.pixinsight.stacker.PixInsightAutoStacker +import nebulosa.stacker.AutoStacker +import java.nio.file.Files +import java.nio.file.Path +import java.util.function.Supplier +import kotlin.io.path.exists +import kotlin.io.path.isRegularFile + +data class StackingRequest( + @JvmField @field:NotNull val outputDirectory: Path? = null, + @JvmField val type: StackerType = StackerType.PIXINSIGHT, + @JvmField @field:NotNull val executablePath: Path? = null, + @JvmField val darkPath: Path? = null, + @JvmField val darkEnabled: Boolean = false, + @JvmField val flatPath: Path? = null, + @JvmField val flatEnabled: Boolean = false, + @JvmField val biasPath: Path? = null, + @JvmField val biasEnabled: Boolean = false, + @JvmField val use32Bits: Boolean = false, + @JvmField val slot: Int = 1, + @JvmField @field:NotNull val referencePath: Path? = null, + @JvmField @field:Size(min = 2) @field:Valid val targets: List = emptyList(), +) : Supplier { + + override fun get(): AutoStacker { + val workingDirectory = Files.createTempDirectory("as-") + + val darkPath = darkPath?.takeIf { darkEnabled && it.exists() && it.isRegularFile() } + val flatPath = flatPath?.takeIf { flatEnabled && it.exists() && it.isRegularFile() } + val biasPath = biasPath?.takeIf { biasEnabled && it.exists() && it.isRegularFile() } + + return when (type) { + StackerType.PIXINSIGHT -> { + val runner = startPixInsight(executablePath!!, slot) + PixInsightAutoStacker(runner, workingDirectory, darkPath, flatPath, biasPath, slot) + } + } + } +} diff --git a/api/src/main/kotlin/nebulosa/api/stacker/StackingTarget.kt b/api/src/main/kotlin/nebulosa/api/stacker/StackingTarget.kt new file mode 100644 index 000000000..a544f999f --- /dev/null +++ b/api/src/main/kotlin/nebulosa/api/stacker/StackingTarget.kt @@ -0,0 +1,11 @@ +package nebulosa.api.stacker + +import jakarta.validation.constraints.NotNull +import java.nio.file.Path + +data class StackingTarget( + @JvmField val enabled: Boolean = true, + @JvmField @field:NotNull val path: Path? = null, + @JvmField val group: StackerGroupType = StackerGroupType.MONO, + @JvmField val debayer: Boolean = true, +) diff --git a/api/src/main/kotlin/nebulosa/api/stardetector/StarDetectionRequest.kt b/api/src/main/kotlin/nebulosa/api/stardetector/StarDetectionRequest.kt index c43e42c1f..982f000a9 100644 --- a/api/src/main/kotlin/nebulosa/api/stardetector/StarDetectionRequest.kt +++ b/api/src/main/kotlin/nebulosa/api/stardetector/StarDetectionRequest.kt @@ -1,9 +1,7 @@ package nebulosa.api.stardetector import nebulosa.astap.stardetector.AstapStarDetector -import nebulosa.pixinsight.script.PixInsightIsRunning -import nebulosa.pixinsight.script.PixInsightScriptRunner -import nebulosa.pixinsight.script.PixInsightStartup +import nebulosa.pixinsight.script.startPixInsight import nebulosa.pixinsight.stardetector.PixInsightStarDetector import nebulosa.siril.stardetector.SirilStarDetector import nebulosa.stardetector.StarDetector @@ -24,14 +22,7 @@ data class StarDetectionRequest( StarDetectorType.ASTAP -> AstapStarDetector(executablePath!!, minSNR) StarDetectorType.SIRIL -> SirilStarDetector(executablePath!!, maxStars) StarDetectorType.PIXINSIGHT -> { - val runner = PixInsightScriptRunner(executablePath!!) - - if (!PixInsightIsRunning(slot).use { it.runSync(runner) }) { - if (!PixInsightStartup(slot).use { it.runSync(runner) }) { - throw IllegalStateException("unable to start PixInsight") - } - } - + val runner = startPixInsight(executablePath!!, slot) PixInsightStarDetector(runner, slot, minSNR, timeout) } } diff --git a/api/src/test/kotlin/SkyAtlasServiceTest.kt b/api/src/test/kotlin/SkyAtlasServiceTest.kt index 6a25036af..90ae1268f 100644 --- a/api/src/test/kotlin/SkyAtlasServiceTest.kt +++ b/api/src/test/kotlin/SkyAtlasServiceTest.kt @@ -143,7 +143,7 @@ class SkyAtlasServiceTest : StringSpec() { position.declinationJ2000.formatSignedDMS() shouldBe "-017°22'47.2\"" position.rightAscension.formatHMS() shouldBe "14h49m00.4s" position.declination.formatSignedDMS() shouldBe "-017°29'00.1\"" - position.azimuth.formatDMS() shouldBe "144°36'58.8\"" + position.azimuth.formatDMS() shouldBe "144°36'58.9\"" position.altitude.formatSignedDMS() shouldBe "-045°07'44.9\"" position.constellation shouldBe Constellation.LIB position.distance shouldBe (9633.950 plusOrMinus 1e-3) diff --git a/api/src/test/kotlin/StackerServiceTest.kt b/api/src/test/kotlin/StackerServiceTest.kt new file mode 100644 index 000000000..77a0f664d --- /dev/null +++ b/api/src/test/kotlin/StackerServiceTest.kt @@ -0,0 +1,70 @@ +import io.kotest.core.annotation.EnabledIf +import io.kotest.matchers.ints.shouldBeExactly +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import nebulosa.api.stacker.* +import nebulosa.fits.fits +import nebulosa.image.Image +import nebulosa.image.algorithms.transformation.AutoScreenTransformFunction +import nebulosa.test.AbstractFitsAndXisfTest +import nebulosa.test.NonGitHubOnlyCondition +import java.nio.file.Path +import kotlin.io.path.createDirectories + +@EnabledIf(NonGitHubOnlyCondition::class) +class StackerServiceTest : AbstractFitsAndXisfTest() { + + init { + val service = StackerService() + + val paths = listOf( + Path.of("$BASE_DIR/20240513.213424625-LIGHT.fits"), + Path.of("$BASE_DIR/20240513.213436506-LIGHT.fits"), + Path.of("$BASE_DIR/20240513.213448253-LIGHT.fits"), + Path.of("$BASE_DIR/20240513.213500627-LIGHT.fits"), + Path.of("$BASE_DIR/20240513.213512554-LIGHT.fits"), + Path.of("$BASE_DIR/20240513.213524278-LIGHT.fits"), + Path.of("$BASE_DIR/20240513.213535967-LIGHT.fits"), + Path.of("$BASE_DIR/20240513.213547683-LIGHT.fits"), + Path.of("$BASE_DIR/20240513.213559416-LIGHT.fits"), + Path.of("$BASE_DIR/20240513.213611421-LIGHT.fits"), + Path.of("$BASE_DIR/20240513.213624939-LIGHT.fits"), + Path.of("$BASE_DIR/20240513.213636654-LIGHT.fits"), + Path.of("$BASE_DIR/20240513.213648389-LIGHT.fits"), + Path.of("$BASE_DIR/20240513.213701880-LIGHT.fits"), + Path.of("$BASE_DIR/20240513.213713546-LIGHT.fits"), + Path.of("$BASE_DIR/20240513.213725316-LIGHT.fits"), + Path.of("$BASE_DIR/20240513.213738803-LIGHT.fits"), + Path.of("$BASE_DIR/20240513.213750501-LIGHT.fits"), + Path.of("$BASE_DIR/20240513.213802188-LIGHT.fits"), + ) + + val darkPath = Path.of("/home/tiagohm/Imagens/Astrophotos/Dark/2024-06-08/ASI294_BIN4_G120_O80/10-DARK.fits") + + "stack LRGB" { + val targets = paths.map { + val analyzed = service.analyze(it)!! + StackingTarget(true, it, analyzed.group, true) + } + + targets.count { it.group == StackerGroupType.LUMINANCE } shouldBeExactly 10 + targets.count { it.group == StackerGroupType.RED } shouldBeExactly 3 + targets.count { it.group == StackerGroupType.GREEN } shouldBeExactly 3 + targets.count { it.group == StackerGroupType.BLUE } shouldBeExactly 3 + + val request = StackingRequest( + Path.of(BASE_DIR, "stacker").createDirectories(), StackerType.PIXINSIGHT, + Path.of("PixInsight"), darkPath, true, null, false, null, false, false, + 1, paths[0], targets + ) + + val image = service.stack(request).shouldNotBeNull().fits().use(Image::open) + image.transform(AutoScreenTransformFunction).save("stacker-lrgb").second shouldBe "465a296bb4582ab2f938757347500eb8" + } + } + + companion object { + + const val BASE_DIR = "/home/tiagohm/Imagens/Astrophotos/Light/Algieba/2024-05-13" + } +} diff --git a/desktop/.editorconfig b/desktop/.editorconfig index 350957ed8..ccc726593 100644 --- a/desktop/.editorconfig +++ b/desktop/.editorconfig @@ -11,6 +11,7 @@ trim_trailing_whitespace = true [*.ts, *.js] quote_type = single +insert_final_newline = true [*.md] trim_trailing_whitespace = false diff --git a/desktop/README.md b/desktop/README.md index 2f0bae907..244054bc3 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -58,6 +58,10 @@ The complete integrated solution for all of your astronomical imaging needs. ![](sequencer.png) +## Stacker + +![](stacker.png) + ## INDI ![](indi.png) diff --git a/desktop/app/window.manager.ts b/desktop/app/window.manager.ts index 9d25ba4ca..965cf851c 100644 --- a/desktop/app/window.manager.ts +++ b/desktop/app/window.manager.ts @@ -288,13 +288,19 @@ export class WindowManager { const window = this.findWindow(command.windowId) ?? this.findWindow(event.sender.id) if (window) { + const properties: Electron.OpenDialogOptions['properties'] = ['openFile'] + + if (command.multiple) { + properties.push('multiSelections') + } + const ret = await dialog.showOpenDialog(window.browserWindow, { filters: command.filters, - properties: ['openFile'], + properties, defaultPath: command.defaultPath || undefined, }) - return !ret.canceled && ret.filePaths[0] + return !ret.canceled && (command.multiple ? ret.filePaths : ret.filePaths[0]) } else { return false } diff --git a/desktop/src/app/about/about.component.html b/desktop/src/app/about/about.component.html index cfddc9af2..c2fc494dd 100644 --- a/desktop/src/app/about/about.component.html +++ b/desktop/src/app/about/about.component.html @@ -39,7 +39,7 @@
Stack icon by Pixel perfect - Flaticon + + Photo filter icon by Freepik - Flaticon + diff --git a/desktop/src/app/app-routing.module.ts b/desktop/src/app/app-routing.module.ts index 04dfdb38a..7f637d3fe 100644 --- a/desktop/src/app/app-routing.module.ts +++ b/desktop/src/app/app-routing.module.ts @@ -20,6 +20,7 @@ import { MountComponent } from './mount/mount.component' import { RotatorComponent } from './rotator/rotator.component' import { SequencerComponent } from './sequencer/sequencer.component' import { SettingsComponent } from './settings/settings.component' +import { StackerComponent } from './stacker/stacker.component' const routes: Routes = [ { @@ -91,6 +92,10 @@ const routes: Routes = [ path: 'auto-focus', component: AutoFocusComponent, }, + { + path: 'stacker', + component: StackerComponent, + }, { path: 'calculator', component: CalculatorComponent, diff --git a/desktop/src/app/app.module.ts b/desktop/src/app/app.module.ts index b16bd44e5..2bc430f40 100644 --- a/desktop/src/app/app.module.ts +++ b/desktop/src/app/app.module.ts @@ -93,6 +93,7 @@ import { MountComponent } from './mount/mount.component' import { RotatorComponent } from './rotator/rotator.component' import { SequencerComponent } from './sequencer/sequencer.component' import { SettingsComponent } from './settings/settings.component' +import { StackerComponent } from './stacker/stacker.component' @NgModule({ declarations: [ @@ -141,6 +142,7 @@ import { SettingsComponent } from './settings/settings.component' SettingsComponent, SkyObjectPipe, SlideMenuComponent, + StackerComponent, StopPropagationDirective, WinPipe, ], diff --git a/desktop/src/app/camera/camera.component.html b/desktop/src/app/camera/camera.component.html index 3fdeabac0..ae4338c98 100644 --- a/desktop/src/app/camera/camera.component.html +++ b/desktop/src/app/camera/camera.component.html @@ -608,8 +608,8 @@ [disabled]="!liveStacking.request.enabled" [directory]="false" label="Dark File" - key="LS_DARK_PATH" - [(path)]="liveStacking.request.dark" + key="LIVE_STACKER_DARK_PATH" + [(path)]="liveStacking.request.darkPath" class="w-full" (pathChange)="savePreference()" />
@@ -618,8 +618,8 @@ [disabled]="!liveStacking.request.enabled" [directory]="false" label="Flat File" - key="LS_FLAT_PATH" - [(path)]="liveStacking.request.flat" + key="LIVE_STACKER_FLAT_PATH" + [(path)]="liveStacking.request.flatPath" class="w-full" (pathChange)="savePreference()" /> @@ -628,8 +628,8 @@ [disabled]="!liveStacking.request.enabled || liveStacking.request.type !== 'PIXINSIGHT'" [directory]="false" label="Bias File" - key="LS_BIAS_PATH" - [(path)]="liveStacking.request.bias" + key="LIVE_STACKER_BIAS_PATH" + [(path)]="liveStacking.request.biasPath" class="w-full" (pathChange)="savePreference()" /> diff --git a/desktop/src/app/camera/camera.component.ts b/desktop/src/app/camera/camera.component.ts index 668fac8f5..43b72eaba 100644 --- a/desktop/src/app/camera/camera.component.ts +++ b/desktop/src/app/camera/camera.component.ts @@ -750,9 +750,9 @@ export class CameraComponent implements AfterContentInit, OnDestroy, Pingable { this.request.liveStacking.enabled = cameraPreference.liveStacking?.enabled ?? false this.request.liveStacking.type = cameraPreference.liveStacking?.type ?? 'SIRIL' this.request.liveStacking.executablePath = cameraPreference.liveStacking?.executablePath ?? '' - this.request.liveStacking.dark = cameraPreference.liveStacking?.dark - this.request.liveStacking.flat = cameraPreference.liveStacking?.flat - this.request.liveStacking.bias = cameraPreference.liveStacking?.bias + this.request.liveStacking.darkPath = cameraPreference.liveStacking?.darkPath + this.request.liveStacking.flatPath = cameraPreference.liveStacking?.flatPath + this.request.liveStacking.biasPath = cameraPreference.liveStacking?.biasPath this.request.liveStacking.use32Bits = cameraPreference.liveStacking?.use32Bits ?? false this.request.liveStacking.slot = cameraPreference.liveStacking?.slot ?? 1 diff --git a/desktop/src/app/home/home.component.html b/desktop/src/app/home/home.component.html index 3d918cf3f..bbd3239fd 100644 --- a/desktop/src/app/home/home.component.html +++ b/desktop/src/app/home/home.component.html @@ -217,6 +217,15 @@
Auto Focus
+
+ + +
Stacker
+
+
@@ -14,7 +13,7 @@
+ *ngIf="tab === 'LOCATION'">
@@ -61,7 +60,7 @@
+ *ngIf="tab === 'PLATE_SOLVER'">
@@ -139,7 +138,7 @@
+ *ngIf="tab === 'STAR_DETECTOR'">
@@ -236,7 +235,7 @@
+ *ngIf="tab === 'LIVE_STACKER'">
@@ -282,7 +281,53 @@
+ *ngIf="tab === 'STACKER'"> +
+
+ + + + +
+
+ +
+
+ + + + +
+
+
+
diff --git a/desktop/src/app/settings/settings.component.ts b/desktop/src/app/settings/settings.component.ts index d164128f5..79441a195 100644 --- a/desktop/src/app/settings/settings.component.ts +++ b/desktop/src/app/settings/settings.component.ts @@ -6,7 +6,8 @@ import { PreferenceService } from '../../shared/services/preference.service' import { PrimeService } from '../../shared/services/prime.service' import { EMPTY_LOCATION, Location } from '../../shared/types/atlas.types' import { FrameType, LiveStackerType, LiveStackingRequest } from '../../shared/types/camera.types' -import { DEFAULT_CAMERA_CAPTURE_NAMING_FORMAT, PlateSolverRequest, PlateSolverType, resetCameraCaptureNamingFormat, StarDetectionRequest, StarDetectorType } from '../../shared/types/settings.types' +import { DEFAULT_CAMERA_CAPTURE_NAMING_FORMAT, PlateSolverRequest, PlateSolverType, resetCameraCaptureNamingFormat, SettingsTabKey, StarDetectionRequest, StarDetectorType } from '../../shared/types/settings.types' +import { StackerType, StackingRequest } from '../../shared/types/stacker.types' import { AppComponent } from '../app.component' @Component({ @@ -14,29 +15,8 @@ import { AppComponent } from '../app.component' templateUrl: './settings.component.html', }) export class SettingsComponent { - tab = 0 - readonly tabs: { id: number; name: string }[] = [ - { - id: 0, - name: 'Location', - }, - { - id: 1, - name: 'Plate Solver', - }, - { - id: 2, - name: 'Star Detection', - }, - { - id: 3, - name: 'Live Stacking', - }, - { - id: 4, - name: 'Capture Naming Format', - }, - ] + tab: SettingsTabKey = 'LOCATION' + readonly tabs: SettingsTabKey[] = ['LOCATION', 'PLATE_SOLVER', 'STAR_DETECTOR', 'LIVE_STACKER', 'STACKER', 'CAPTURE_NAMING_FORMAT'] readonly locations: Location[] location: Location @@ -50,6 +30,9 @@ export class SettingsComponent { liveStackerType: LiveStackerType = 'SIRIL' readonly liveStackers = new Map() + stackerType: StackerType = 'PIXINSIGHT' + readonly stackers = new Map() + readonly cameraCaptureNamingFormat = structuredClone(DEFAULT_CAMERA_CAPTURE_NAMING_FORMAT) constructor( @@ -73,6 +56,9 @@ export class SettingsComponent { for (const type of dropdownOptions.transform('LIVE_STACKER')) { this.liveStackers.set(type, preference.liveStackingRequest(type).get()) } + for (const type of dropdownOptions.transform('STACKER')) { + this.stackers.set(type, preference.stackingRequest(type).get()) + } Object.assign(this.cameraCaptureNamingFormat, preference.cameraCaptureNamingFormatPreference.get(this.cameraCaptureNamingFormat)) } @@ -146,6 +132,9 @@ export class SettingsComponent { for (const type of this.dropdownOptions.transform('LIVE_STACKER')) { this.preference.liveStackingRequest(type).set(this.liveStackers.get(type)) } + for (const type of this.dropdownOptions.transform('STACKER')) { + this.preference.stackingRequest(type).set(this.stackers.get(type)) + } this.preference.cameraCaptureNamingFormatPreference.set(this.cameraCaptureNamingFormat) } diff --git a/desktop/src/app/stacker/stacker.component.html b/desktop/src/app/stacker/stacker.component.html new file mode 100644 index 000000000..086859656 --- /dev/null +++ b/desktop/src/app/stacker/stacker.component.html @@ -0,0 +1,174 @@ +
+
+ +
+
+ +
+
+ + + + +
+
+
+ {{ item.type }} + +
+ Reference + +
+
+ Enabled + +
+
+ @if (item.analyzed) { +
+ EXP: {{ item.analyzed.exposureTime | exposureTime }} + WIDTH: {{ item.analyzed.width }} + HEIGHT: {{ item.analyzed.height }} + BIN: {{ item.analyzed.binX }}x{{ item.analyzed.binY }} + GAIN: {{ item.analyzed.gain }} +
+ } +
+ {{ item.path }} +
+
+
+ +
+
+
+
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + + +
+
+
+
+
+ + +
+
diff --git a/desktop/src/app/stacker/stacker.component.ts b/desktop/src/app/stacker/stacker.component.ts new file mode 100644 index 000000000..4692a91f0 --- /dev/null +++ b/desktop/src/app/stacker/stacker.component.ts @@ -0,0 +1,153 @@ +import { AfterViewInit, Component } from '@angular/core' +import { dirname } from 'path' +import { ApiService } from '../../shared/services/api.service' +import { BrowserWindowService } from '../../shared/services/browser-window.service' +import { ElectronService } from '../../shared/services/electron.service' +import { PreferenceService } from '../../shared/services/preference.service' +import { EMPTY_STACKING_REQUEST, StackingRequest, StackingTarget } from '../../shared/types/stacker.types' +import { AppComponent } from '../app.component' + +@Component({ + selector: 'neb-stacker', + templateUrl: './stacker.component.html', +}) +export class StackerComponent implements AfterViewInit { + running = false + readonly request = structuredClone(EMPTY_STACKING_REQUEST) + + get referenceTarget() { + return this.request.targets.find((e) => e.enabled && e.reference && e.type === 'LIGHT') + } + + get hasReference() { + return !!this.referenceTarget + } + + get canStart() { + return !!this.request.outputDirectory && this.hasReference + } + + constructor( + app: AppComponent, + private readonly electron: ElectronService, + private readonly api: ApiService, + private readonly preference: PreferenceService, + private readonly browserWindow: BrowserWindowService, + ) { + app.title = 'Stacker' + } + + async ngAfterViewInit() { + this.loadPreference() + + this.running = await this.api.stackerIsRunning() + } + + async openImages() { + try { + this.running = true + + const stackerPreference = this.preference.stackerPreference.get() + const images = await this.electron.openImages({ defaultPath: stackerPreference.defaultPath }) + + if (images && images.length) { + const targets: StackingTarget[] = [...this.request.targets] + + for (const path of images) { + const analyzed = await this.api.stackerAnalyze(path) + + if (analyzed && analyzed.type === 'LIGHT') { + targets.push({ + enabled: true, + path, + analyzed, + type: analyzed.type, + group: analyzed.group, + reference: !targets.length && !this.referenceTarget, + }) + } + } + + this.request.targets = targets + + stackerPreference.defaultPath = dirname(images[0]) + this.preference.stackerPreference.set(stackerPreference) + } + } finally { + this.running = false + } + } + + referenceChanged(target: StackingTarget, enabled: boolean) { + if (enabled) { + for (const item of this.request.targets) { + if (item.reference && item !== target) { + item.reference = false + } + } + } + } + + deleteTarget(target: StackingTarget) { + const index = this.request.targets.findIndex((e) => e === target) + + if (index >= 0) { + this.request.targets.splice(index, 1) + } + } + + async startStacking() { + const stackingRequest = this.preference.stackingRequest(this.request.type).get() + this.request.executablePath = stackingRequest.executablePath + this.request.slot = stackingRequest.slot || 1 + this.request.referencePath = this.referenceTarget!.path + + const request: StackingRequest = { + ...this.request, + targets: this.request.targets.filter((e) => e.enabled), + } + + this.savePreference() + + try { + this.running = true + const path = await this.api.stackerStart(request) + + if (path) { + await this.browserWindow.openImage({ path, source: 'STACKER' }) + } + } finally { + this.running = false + } + } + + stopStacking() { + return this.api.stackerStop() + } + + private loadPreference() { + const stackerPreference = this.preference.stackerPreference.get() + + this.request.outputDirectory = stackerPreference.outputDirectory ?? '' + this.request.darkPath = stackerPreference.darkPath + this.request.darkEnabled = stackerPreference.darkEnabled ?? false + this.request.flatPath = stackerPreference.flatPath + this.request.flatEnabled = stackerPreference.flatEnabled ?? false + this.request.biasPath = stackerPreference.biasPath + this.request.biasEnabled = stackerPreference.biasEnabled ?? false + this.request.type = stackerPreference.type ?? 'PIXINSIGHT' + } + + savePreference() { + const stackerPreference = this.preference.stackerPreference.get() + stackerPreference.outputDirectory = this.request.outputDirectory + stackerPreference.darkPath = this.request.darkPath + stackerPreference.darkEnabled = this.request.darkEnabled + stackerPreference.flatPath = this.request.flatPath + stackerPreference.flatEnabled = this.request.flatEnabled + stackerPreference.biasPath = this.request.biasPath + stackerPreference.biasEnabled = this.request.biasEnabled + stackerPreference.type = this.request.type + this.preference.stackerPreference.set(stackerPreference) + } +} diff --git a/desktop/src/assets/icons/photo-filter.png b/desktop/src/assets/icons/photo-filter.png new file mode 100644 index 000000000..d3f7a892d Binary files /dev/null and b/desktop/src/assets/icons/photo-filter.png differ diff --git a/desktop/src/shared/pipes/dropdown-options.pipe.ts b/desktop/src/shared/pipes/dropdown-options.pipe.ts index a0fed50c1..cc5b4c34b 100644 --- a/desktop/src/shared/pipes/dropdown-options.pipe.ts +++ b/desktop/src/shared/pipes/dropdown-options.pipe.ts @@ -6,7 +6,8 @@ import { GuideDirection, GuiderPlotMode, GuiderYAxisUnit } from '../types/guider import { Bitpix, ImageChannel, ImageFormat, SCNRProtectionMethod } from '../types/image.types' import { MountRemoteControlType } from '../types/mount.types' import { SequenceCaptureMode } from '../types/sequencer.types' -import { PlateSolverType, StarDetectorType } from '../types/settings.types' +import { PlateSolverType, SettingsTabKey, StarDetectorType } from '../types/settings.types' +import { StackerGroupType, StackerType } from '../types/stacker.types' export interface DropdownOptions { STAR_DETECTOR: StarDetectorType[] @@ -28,6 +29,9 @@ export interface DropdownOptions { GUIDER_PLOT_MODE: GuiderPlotMode[] GUIDER_Y_AXIS_UNIT: GuiderYAxisUnit[] SEQUENCE_CAPTURE_MODE: SequenceCaptureMode[] + STACKER: StackerType[] + SETTINGS_TAB: SettingsTabKey[] + STACKER_GROUP_TYPE: StackerGroupType[] } @Pipe({ name: 'dropdownOptions' }) @@ -72,6 +76,12 @@ export class DropdownOptionsPipe implements PipeTransform { return ['ARCSEC', 'PIXEL'] as DropdownOptions[K] case 'SEQUENCE_CAPTURE_MODE': return ['FULLY', 'INTERLEAVED'] as DropdownOptions[K] + case 'STACKER': + return ['PIXINSIGHT'] as DropdownOptions[K] + case 'SETTINGS_TAB': + return ['LOCATION', 'PLATE_SOLVER', 'STAR_DETECTOR', 'LIVE_STACKER', 'STACKER', 'CAPTURE_NAMING_FORMAT'] as DropdownOptions[K] + case 'STACKER_GROUP_TYPE': + return ['LUMINANCE', 'RED', 'GREEN', 'BLUE', 'MONO', 'RGB'] as DropdownOptions[K] } return [] diff --git a/desktop/src/shared/pipes/enum.pipe.ts b/desktop/src/shared/pipes/enum.pipe.ts index 4a09f52ec..69fa7a87c 100644 --- a/desktop/src/shared/pipes/enum.pipe.ts +++ b/desktop/src/shared/pipes/enum.pipe.ts @@ -8,7 +8,8 @@ import { GuideDirection, GuideState, GuiderPlotMode, GuiderYAxisUnit } from '../ import { Bitpix, SCNRProtectionMethod } from '../types/image.types' import { MountRemoteControlType } from '../types/mount.types' import { SequenceCaptureMode } from '../types/sequencer.types' -import { PlateSolverType, StarDetectorType } from '../types/settings.types' +import { PlateSolverType, SettingsTabKey, StarDetectorType } from '../types/settings.types' +import { StackerGroupType, StackerType } from '../types/stacker.types' import { Undefinable } from '../utils/types' export type EnumPipeKey = @@ -36,11 +37,16 @@ export type EnumPipeKey = | MountRemoteControlType | SequenceCaptureMode | Bitpix + | StackerType + | StackerGroupType + | SettingsTabKey | 'ALL' @Pipe({ name: 'enum' }) export class EnumPipe implements PipeTransform { readonly enums: Record> = { + 'DX/DY': 'dx/dy', + 'RA/DEC': 'RA/DEC', ABSOLUTE: 'Absolute', ACTIVE_GALAXY_NUCLEUS: 'Active Galaxy Nucleus', ACTIVE: 'Active', @@ -57,10 +63,13 @@ export class EnumPipe implements PipeTransform { AQL: 'Aquila', AQR: 'Aquarius', ARA: 'Ara', + ARCSEC: 'Arcsec', ARGOS: 'ARGOS Data Collection System', ARI: 'Aries', ASSOCIATION_OF_STARS: 'Association of Stars', ASTAP: 'Astap', + ASTROMETRY_NET_ONLINE: 'Astrometry.net (Online)', + ASTROMETRY_NET: 'Astrometry.net', ASYMPTOTIC_GIANT_BRANCH_STAR: 'Asymptotic Giant Branch Star', AUR: 'Auriga', AVERAGE_NEUTRAL: 'Average Neutral', @@ -76,16 +85,19 @@ export class EnumPipe implements PipeTransform { BLUE_OBJECT: 'Blue Object', BLUE_STRAGGLER: 'Blue Straggler', BLUE_SUPERGIANT: 'Blue Supergiant', + BLUE: 'Blue', BOO: 'Boötes', BRIGHTEST_GALAXY_IN_A_CLUSTER_BCG: 'Brightest Galaxy in a Cluster (BCG)', BROWN_DWARF: 'Brown Dwarf', BUBBLE: 'Bubble', BY_DRA_VARIABLE: 'BY Dra Variable', + BYTE: 'Byte', CAE: 'Caelum', CALIBRATING: 'Calibrating', CAM: 'Camelopardalis', CAP: 'Capricornus', CAPTURE_FINISHED: undefined, + CAPTURE_NAMING_FORMAT: 'Capture Naming Format', CAPTURE_STARTED: undefined, CAPTURED: 'Captured', CAR: 'Carina', @@ -134,7 +146,9 @@ export class EnumPipe implements PipeTransform { DMC: 'Disaster Monitoring', DOR: 'Dorado', DOUBLE_OR_MULTIPLE_STAR: 'Double or Multiple Star', + DOUBLE: 'Double', DRA: 'Draco', + EAST: 'East', ECLIPSING_BINARY: 'Eclipsing Binary', EDUCATION: 'Education', ELLIPSOIDAL_VARIABLE: 'Ellipsoidal Variable', @@ -158,8 +172,10 @@ export class EnumPipe implements PipeTransform { FINISHED: 'Finished', FIXED: 'Fixed', FLAT: 'Flat', + FLOAT: 'Float', FOR: 'Fornax', FORWARD: 'Forward', + FULLY: 'Fully', GALAXY_IN_PAIR_OF_GALAXIES: 'Galaxy in Pair of Galaxies', GALAXY_TOWARDS_A_CLUSTER_OF_GALAXIES: 'Galaxy towards a Cluster of Galaxies', GALAXY_TOWARDS_A_GROUP_OF_GALAXIES: 'Galaxy towards a Group of Galaxies', @@ -186,6 +202,7 @@ export class EnumPipe implements PipeTransform { GRAVITATIONALLY_LENSED_IMAGE_OF_A_GALAXY: 'Gravitationally Lensed Image of a Galaxy', GRAVITATIONALLY_LENSED_IMAGE_OF_A_QUASAR: 'Gravitationally Lensed Image of a Quasar', GRAVITATIONALLY_LENSED_IMAGE: 'Gravitationally Lensed Image', + GREEN: 'Green', GROUP_OF_GALAXIES: 'Group of Galaxies', GRU: 'Grus', GUIDING: 'Guiding', @@ -209,8 +226,10 @@ export class EnumPipe implements PipeTransform { IND: 'Indus', INFRA_RED_SOURCE: 'Infra-Red Source', INITIAL_PAUSE: 'Initial Pause', + INTEGER: 'Integer', INTELSAT: 'Intelsat', INTERACTING_GALAXIES: 'Interacting Galaxies', + INTERLEAVED: 'Interleaved', INTERSTELLAR_FILAMENT: 'Interstellar Filament', INTERSTELLAR_MEDIUM_OBJECT: 'Interstellar Medium Object', INTERSTELLAR_SHELL: 'Interstellar Shell', @@ -225,15 +244,20 @@ export class EnumPipe implements PipeTransform { LIB: 'Libra', LIGHT: 'Light', LINER_TYPE_ACTIVE_GALAXY_NUCLEUS: 'LINER-type Active Galaxy Nucleus', + LIVE_STACKER: 'Live Stacker', LMI: 'Leo Minor', + LOCATION: 'Location', LONG_PERIOD_VARIABLE: 'Long-Period Variable', + LONG: 'Long', LOOP: 'Loop', LOOPING: 'Looping', LOST_LOCK: 'Lost Lock', LOW_MASS_STAR: 'Low-mass Star', LOW_MASS_X_RAY_BINARY: 'Low Mass X-ray Binary', LOW_SURFACE_BRIGHTNESS_GALAXY: 'Low Surface Brightness Galaxy', + LUMINANCE: 'Luminance', LUP: 'Lupus', + LX200: 'LX200', LYN: 'Lynx', LYR: 'Lyra', MAIN_SEQUENCE_STAR: 'Main Sequence Star', @@ -253,6 +277,7 @@ export class EnumPipe implements PipeTransform { MOLECULAR_CLOUD: 'Molecular Cloud', MOLNIYA: 'Molniya', MON: 'Monoceros', + MONO: 'Mono', MOVING_GROUP: 'Moving Group', MOVING: 'Moving', MUS: 'Musca', @@ -264,6 +289,8 @@ export class EnumPipe implements PipeTransform { NOAA: 'NOAA', NONE: 'None', NOR: 'Norma', + NORTH: 'North', + NORTHERN: 'Northern', NOT_AN_OBJECT_ERROR_ARTEFACT: 'Not an Object (Error, Artefact, ...)', OBJECT_OF_UNKNOWN_NATURE: 'Object of Unknown Nature', OCT: 'Octans', @@ -290,9 +317,11 @@ export class EnumPipe implements PipeTransform { PER: 'Perseus', PHE: 'Phoenix', PIC: 'Pictor', + PIXEL: 'Pixel', PIXINSIGHT: 'PixInsight', PLANET: 'Planet', PLANETARY_NEBULA: 'Planetary Nebula', + PLATE_SOLVER: 'Plate Solver', POST_AGB_STAR: 'Post-AGB Star', PROTO_CLUSTER_OF_GALAXIES: 'Proto Cluster of Galaxies', PSA: 'Piscis Austrinus', @@ -310,10 +339,12 @@ export class EnumPipe implements PipeTransform { RADUGA: 'Raduga', RED_GIANT_BRANCH_STAR: 'Red Giant Branch star', RED_SUPERGIANT: 'Red Supergiant', + RED: 'Red', REFLECTION_NEBULA: 'Reflection Nebula', REGION_DEFINED_IN_THE_SKY: 'Region defined in the Sky', RESOURCE: 'Earth Resources', RET: 'Reticulum', + RGB: 'RGB', ROTATING_VARIABLE: 'Rotating Variable', RR_LYRAE_VARIABLE: 'RR Lyrae Variable', RS_CVN_VARIABLE: 'RS CVn Variable', @@ -336,21 +367,27 @@ export class EnumPipe implements PipeTransform { SEYFERT_GALAXY: 'Seyfert Galaxy', SGE: 'Sagitta', SGR: 'Sagittarius', + SHORT: 'Short', SINGLE: 'Single', SIRIL: 'Siril', SLEWED: 'Slewed', SLEWING: 'Slewing', SOLVED: 'Solved', SOLVING: 'Solving', + SOUTH: 'South', + SOUTHERN: 'Southern', SPECTROSCOPIC_BINARY: 'Spectroscopic Binary', SPIRE: 'Spire', + STACKER: 'Stacker', STACKING: 'Stacking', + STAR_DETECTOR: 'Star Detector', STAR_FORMING_REGION: 'Star Forming Region', STAR: 'Star', STARBURST_GALAXY: 'Starburst Galaxy', STARLINK: 'Starlink', STATIONS: 'Space Stations', STELLAR_STREAM: 'Stellar Stream', + STELLARIUM: 'Stellarium', STOPPED: 'Stopped', SUB_MILLIMETRIC_SOURCE: 'Sub-Millimetric Source', SUPERCLUSTER_OF_GALAXIES: 'Supercluster of Galaxies', @@ -384,6 +421,7 @@ export class EnumPipe implements PipeTransform { VUL: 'Vulpecula', WAITING: 'Waiting', WEATHER: 'Weather', + WEST: 'West', WHITE_DWARF: 'White Dwarf', WOLF_RAYET: 'Wolf-Rayet', X_COMM: 'Experimental Comm', @@ -391,28 +429,6 @@ export class EnumPipe implements PipeTransform { X_RAY_SOURCE: 'X-ray Source', YELLOW_SUPERGIANT: 'Yellow Supergiant', YOUNG_STELLAR_OBJECT: 'Young Stellar Object', - ASTROMETRY_NET: 'Astrometry.net', - ASTROMETRY_NET_ONLINE: 'Astrometry.net (Online)', - NORTH: 'North', - NORTHERN: 'Northern', - SOUTH: 'South', - SOUTHERN: 'Southern', - WEST: 'West', - EAST: 'East', - 'RA/DEC': 'RA/DEC', - 'DX/DY': 'dx/dy', - ARCSEC: 'Arcsec', - PIXEL: 'Pixel', - LX200: 'LX200', - STELLARIUM: 'Stellarium', - FULLY: 'Fully', - INTERLEAVED: 'Interleaved', - BYTE: 'Byte', - SHORT: 'Short', - INTEGER: 'Integer', - LONG: 'Long', - FLOAT: 'Float', - DOUBLE: 'Double', } // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents diff --git a/desktop/src/shared/services/api.service.ts b/desktop/src/shared/services/api.service.ts index 5e2366079..b7efe8e25 100644 --- a/desktop/src/shared/services/api.service.ts +++ b/desktop/src/shared/services/api.service.ts @@ -16,6 +16,7 @@ import { CelestialLocationType, Mount, MountRemoteControl, MountRemoteControlTyp import { Rotator } from '../types/rotator.types' import { SequencePlan } from '../types/sequencer.types' import { PlateSolverRequest, StarDetectionRequest } from '../types/settings.types' +import { AnalyzedTarget, StackingRequest } from '../types/stacker.types' import { FilterWheel } from '../types/wheel.types' import { Undefinable } from '../utils/types' import { HttpService } from './http.service' @@ -680,26 +681,22 @@ export class ApiService { return this.http.put(`auto-focus/${camera.id}/stop`) } - // PREFERENCE + // STACKER - clearPreferences() { - return this.http.put('preferences/clear') + stackerStart(request: StackingRequest) { + return this.http.put('stacker/start', request) } - deletePreference(key: string) { - return this.http.delete(`preferences/${key}`) + stackerIsRunning() { + return this.http.get('stacker/running') } - getPreference(key: string) { - return this.http.get(`preferences/${key}`) + stackerStop() { + return this.http.put('stacker/stop') } - setPreference(key: string, data: unknown) { - return this.http.put(`preferences/${key}`, { data }) - } - - hasPreference(key: string) { - return this.http.get(`preferences/${key}/exists`) + stackerAnalyze(path: string) { + return this.http.put(`stacker/analyze?path=${path}`) } // CONFIRMATION diff --git a/desktop/src/shared/services/browser-window.service.ts b/desktop/src/shared/services/browser-window.service.ts index c96e3e34b..9504f266d 100644 --- a/desktop/src/shared/services/browser-window.service.ts +++ b/desktop/src/shared/services/browser-window.service.ts @@ -139,10 +139,15 @@ export class BrowserWindowService { } openCalibration(preference: WindowPreference = {}) { - Object.assign(preference, { icon: 'stack', width: 420, height: 400, minHeight: 400 }) + Object.assign(preference, { icon: 'photo-filter', width: 420, height: 400, minHeight: 400 }) return this.openWindow({ preference, id: 'calibration', path: 'calibration' }) } + openStacker(preference: WindowPreference = {}) { + Object.assign(preference, { icon: 'stack', width: 370, height: 460 }) + return this.openWindow({ preference, id: 'stacker', path: 'stacker' }) + } + openAbout() { const preference: WindowPreference = { icon: 'about', width: 430, height: 307, bringToFront: true } return this.openWindow({ preference, id: 'about', path: 'about' }) diff --git a/desktop/src/shared/services/electron.service.ts b/desktop/src/shared/services/electron.service.ts index f54a7bf0b..06d1637e7 100644 --- a/desktop/src/shared/services/electron.service.ts +++ b/desktop/src/shared/services/electron.service.ts @@ -23,7 +23,12 @@ import { Mount } from '../types/mount.types' import { Rotator } from '../types/rotator.types' import { SequencerEvent } from '../types/sequencer.types' import { FilterWheel, WheelRenamed } from '../types/wheel.types' -import { Undefinable } from '../utils/types' + +export const IMAGE_FILE_FILTER: Electron.FileFilter[] = [ + { name: 'All', extensions: ['fits', 'fit', 'xisf'] }, + { name: 'FITS', extensions: ['fits', 'fit'] }, + { name: 'XISF', extensions: ['xisf'] }, +] interface EventMappedType { NOTIFICATION: NotificationEvent @@ -128,23 +133,31 @@ export class ElectronService { }) } - openFile(data?: OpenFile): Promise> { - return this.send('FILE.OPEN', { ...data, windowId: data?.windowId ?? window.id }) + openFile(data?: OpenFile): Promise { + return this.send('FILE.OPEN', { ...data, windowId: data?.windowId ?? window.id, multiple: false }) + } + + openFiles(data?: OpenFile): Promise { + return this.send('FILE.OPEN', { ...data, windowId: data?.windowId ?? window.id, multiple: true }) } - saveFile(data?: OpenFile): Promise> { + saveFile(data?: OpenFile): Promise { return this.send('FILE.SAVE', { ...data, windowId: data?.windowId ?? window.id }) } - openImage(data?: OpenFile): Promise> { + openImage(data?: OpenFile) { return this.openFile({ ...data, windowId: data?.windowId ?? window.id, - filters: [ - { name: 'All', extensions: ['fits', 'fit', 'xisf'] }, - { name: 'FITS', extensions: ['fits', 'fit'] }, - { name: 'XISF', extensions: ['xisf'] }, - ], + filters: IMAGE_FILE_FILTER, + }) + } + + openImages(data?: OpenFile) { + return this.openFiles({ + ...data, + windowId: data?.windowId ?? window.id, + filters: IMAGE_FILE_FILTER, }) } @@ -166,7 +179,7 @@ export class ElectronService { } async saveJson(data: SaveJson): Promise | false> { - data.path = data.path || (await this.saveFile({ ...data, windowId: data.windowId ?? window.id, filters: [{ name: 'JSON files', extensions: ['json'] }] })) + data.path = data.path || (await this.saveFile({ ...data, windowId: data.windowId ?? window.id, filters: [{ name: 'JSON files', extensions: ['json'] }] })) || undefined if (data.path) { if (await this.writeJson(data)) { diff --git a/desktop/src/shared/services/preference.service.ts b/desktop/src/shared/services/preference.service.ts index a346fb1a5..5d30d03a4 100644 --- a/desktop/src/shared/services/preference.service.ts +++ b/desktop/src/shared/services/preference.service.ts @@ -12,6 +12,7 @@ import { EMPTY_MOUNT_PREFERENCE, Mount, MountPreference } from '../types/mount.t import { Rotator, RotatorPreference } from '../types/rotator.types' import { EMPTY_SEQUENCER_PREFERENCE, SequencerPreference } from '../types/sequencer.types' import { CameraCaptureNamingFormat, DEFAULT_CAMERA_CAPTURE_NAMING_FORMAT, EMPTY_PLATE_SOLVER_REQUEST, EMPTY_STAR_DETECTION_REQUEST, PlateSolverRequest, PlateSolverType, StarDetectionRequest, StarDetectorType } from '../types/settings.types' +import { EMPTY_STACKER_PREFERENCE, EMPTY_STACKING_REQUEST, StackerPreference, StackerType, StackingRequest } from '../types/stacker.types' import { FilterWheel, WheelPreference } from '../types/wheel.types' import { Undefinable } from '../utils/types' import { LocalStorageService } from './local-storage.service' @@ -84,6 +85,10 @@ export class PreferenceService { return new PreferenceData(this.storage, `liveStacking.${type}`, () => ({ ...EMPTY_LIVE_STACKING_REQUEST, type }) as LiveStackingRequest) } + stackingRequest(type: StackerType) { + return new PreferenceData(this.storage, `stacking.${type}`, () => ({ ...EMPTY_STACKING_REQUEST, type }) as StackingRequest) + } + equipmentForDevice(device: Device) { return new PreferenceData(this.storage, `equipment.${device.name}`, () => ({}) as Equipment) } @@ -112,4 +117,5 @@ export class PreferenceService { readonly autoFocusPreference = new PreferenceData(this.storage, 'autoFocus', () => structuredClone(EMPTY_AUTO_FOCUS_PREFERENCE)) readonly sequencerPreference = new PreferenceData(this.storage, 'sequencer', () => structuredClone(EMPTY_SEQUENCER_PREFERENCE)) readonly cameraCaptureNamingFormatPreference = new PreferenceData(this.storage, 'camera.namingFormat', () => structuredClone(DEFAULT_CAMERA_CAPTURE_NAMING_FORMAT)) + readonly stackerPreference = new PreferenceData(this.storage, 'stacker', () => structuredClone(EMPTY_STACKER_PREFERENCE)) } diff --git a/desktop/src/shared/types/app.types.ts b/desktop/src/shared/types/app.types.ts index a819efe98..5c95403fe 100644 --- a/desktop/src/shared/types/app.types.ts +++ b/desktop/src/shared/types/app.types.ts @@ -80,6 +80,7 @@ export interface OpenDirectory extends WindowCommand { export interface OpenFile extends OpenDirectory { filters?: Electron.FileFilter[] + multiple?: boolean } export interface JsonFile { diff --git a/desktop/src/shared/types/camera.types.ts b/desktop/src/shared/types/camera.types.ts index e0bd4123d..4948c7139 100644 --- a/desktop/src/shared/types/camera.types.ts +++ b/desktop/src/shared/types/camera.types.ts @@ -293,9 +293,9 @@ export interface LiveStackingRequest { enabled: boolean type: LiveStackerType executablePath: string - dark?: string - flat?: string - bias?: string + darkPath?: string + flatPath?: string + biasPath?: string use32Bits: boolean slot: number } diff --git a/desktop/src/shared/types/home.types.ts b/desktop/src/shared/types/home.types.ts index 3af6d20fb..14a1c01f3 100644 --- a/desktop/src/shared/types/home.types.ts +++ b/desktop/src/shared/types/home.types.ts @@ -5,7 +5,7 @@ import type { Mount } from './mount.types' import type { Rotator } from './rotator.types' import type { FilterWheel } from './wheel.types' -export type HomeWindowType = DeviceType | 'GUIDER' | 'SKY_ATLAS' | 'ALIGNMENT' | 'SEQUENCER' | 'IMAGE' | 'FRAMING' | 'INDI' | 'SETTINGS' | 'CALCULATOR' | 'ABOUT' | 'FLAT_WIZARD' | 'AUTO_FOCUS' +export type HomeWindowType = DeviceType | 'GUIDER' | 'SKY_ATLAS' | 'ALIGNMENT' | 'SEQUENCER' | 'IMAGE' | 'FRAMING' | 'INDI' | 'SETTINGS' | 'CALCULATOR' | 'ABOUT' | 'FLAT_WIZARD' | 'AUTO_FOCUS' | 'STACKER' export const CONNECTION_TYPES = ['INDI', 'ALPACA'] as const diff --git a/desktop/src/shared/types/image.types.ts b/desktop/src/shared/types/image.types.ts index e9ca592d8..d1b4610d4 100644 --- a/desktop/src/shared/types/image.types.ts +++ b/desktop/src/shared/types/image.types.ts @@ -7,7 +7,7 @@ export type ImageChannel = 'RED' | 'GREEN' | 'BLUE' | 'GRAY' export type SCNRProtectionMethod = 'MAXIMUM_MASK' | 'ADDITIVE_MASK' | 'AVERAGE_NEUTRAL' | 'MAXIMUM_NEUTRAL' | 'MINIMUM_NEUTRAL' -export type ImageSource = 'FRAMING' | 'PATH' | 'CAMERA' | 'FLAT_WIZARD' | 'SEQUENCER' | 'ALIGNMENT' | 'AUTO_FOCUS' +export type ImageSource = 'FRAMING' | 'PATH' | 'CAMERA' | 'FLAT_WIZARD' | 'SEQUENCER' | 'ALIGNMENT' | 'AUTO_FOCUS' | 'STACKER' export type ImageFormat = 'FITS' | 'XISF' | 'PNG' | 'JPG' diff --git a/desktop/src/shared/types/settings.types.ts b/desktop/src/shared/types/settings.types.ts index 2a0d19960..c906522dd 100644 --- a/desktop/src/shared/types/settings.types.ts +++ b/desktop/src/shared/types/settings.types.ts @@ -47,6 +47,13 @@ export interface CameraCaptureNamingFormat { bias?: string } +export type SettingsTabKey = 'LOCATION' | 'PLATE_SOLVER' | 'STAR_DETECTOR' | 'LIVE_STACKER' | 'STACKER' | 'CAPTURE_NAMING_FORMAT' + +export interface SettingsTab { + id: SettingsTabKey + name: string +} + export const DEFAULT_CAMERA_CAPTURE_NAMING_FORMAT: CameraCaptureNamingFormat = { light: '[camera]_[type]_[year:2][month][day][hour][min][sec][ms]_[filter]_[width]_[height]_[exp]_[bin]_[gain]', dark: '[camera]_[type]_[width]_[height]_[exp]_[bin]_[gain]', diff --git a/desktop/src/shared/types/stacker.types.ts b/desktop/src/shared/types/stacker.types.ts new file mode 100644 index 000000000..f2971eeff --- /dev/null +++ b/desktop/src/shared/types/stacker.types.ts @@ -0,0 +1,72 @@ +import type { FrameType } from './camera.types' + +export type StackerType = 'PIXINSIGHT' + +export type StackerGroupType = 'LUMINANCE' | 'RED' | 'GREEN' | 'BLUE' | 'MONO' | 'RGB' + +export interface StackingRequest { + outputDirectory: string + type: StackerType + executablePath: string + darkPath?: string + darkEnabled: boolean + flatPath?: string + flatEnabled: boolean + biasPath?: string + biasEnabled: boolean + use32Bits: boolean + slot: number + referencePath: string + targets: StackingTarget[] +} + +export const EMPTY_STACKING_REQUEST: StackingRequest = { + outputDirectory: '', + type: 'PIXINSIGHT', + executablePath: '', + use32Bits: false, + slot: 1, + referencePath: '', + targets: [], + darkEnabled: false, + flatEnabled: false, + biasEnabled: false, +} + +export interface StackingTarget { + enabled: boolean + path: string + type: FrameType + group: StackerGroupType + reference: boolean + analyzed?: AnalyzedTarget +} + +export interface AnalyzedTarget { + width: number + height: number + binX: number + binY: number + gain: number + exposureTime: number + type: FrameType + group: StackerGroupType +} + +export interface StackerPreference { + type?: StackerType + outputDirectory?: string + defaultPath?: string + darkPath?: string + darkEnabled?: boolean + flatPath?: string + flatEnabled?: boolean + biasPath?: string + biasEnabled?: boolean +} + +export const EMPTY_STACKER_PREFERENCE: StackerPreference = { + darkEnabled: false, + flatEnabled: false, + biasEnabled: false, +} diff --git a/desktop/stacker.png b/desktop/stacker.png new file mode 100644 index 000000000..f7dabfdd5 Binary files /dev/null and b/desktop/stacker.png differ diff --git a/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/cancel/CancellationToken.kt b/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/cancel/CancellationToken.kt index 3ee538fb8..837a300e4 100644 --- a/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/cancel/CancellationToken.kt +++ b/nebulosa-common/src/main/kotlin/nebulosa/common/concurrency/cancel/CancellationToken.kt @@ -2,6 +2,7 @@ package nebulosa.common.concurrency.cancel import nebulosa.common.concurrency.latch.Pauser import java.io.Closeable +import java.util.concurrent.CancellationException import java.util.concurrent.CompletableFuture import java.util.concurrent.Future import java.util.concurrent.TimeUnit @@ -43,6 +44,11 @@ class CancellationToken private constructor(private val completable: Completable listeners.remove(listener) } + @Synchronized + fun unlistenAll() { + listeners.clear() + } + fun cancel() { cancel(true) } @@ -74,6 +80,10 @@ class CancellationToken private constructor(private val completable: Completable return completable?.get(timeout, unit) ?: CancellationSource.None } + fun throwIfCancelled() { + if (isCancelled) throw CancellationException() + } + override fun close() { super.close() diff --git a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/camera/FrameType.kt b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/camera/FrameType.kt index f6bb25e2b..33e481bc7 100644 --- a/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/camera/FrameType.kt +++ b/nebulosa-indi-device/src/main/kotlin/nebulosa/indi/device/camera/FrameType.kt @@ -1,8 +1,23 @@ package nebulosa.indi.device.camera +import nebulosa.fits.frame +import nebulosa.image.format.ReadableHeader + enum class FrameType(@JvmField val description: String) { LIGHT("Light"), DARK("Dark"), FLAT("Flat"), - BIAS("Bias"), + BIAS("Bias"); + + companion object { + + @JvmStatic val ReadableHeader.frameType + get() = frame?.let { + if (it.contains("LIGHT", true)) LIGHT + else if (it.contains("DARK", true)) DARK + else if (it.contains("FLAT", true)) FLAT + else if (it.contains("BIAS", true)) BIAS + else null + } + } } diff --git a/nebulosa-pixinsight/build.gradle.kts b/nebulosa-pixinsight/build.gradle.kts index a82b0f0a5..73c3ed243 100644 --- a/nebulosa-pixinsight/build.gradle.kts +++ b/nebulosa-pixinsight/build.gradle.kts @@ -7,10 +7,12 @@ dependencies { api(project(":nebulosa-common")) api(project(":nebulosa-math")) api(project(":nebulosa-stardetector")) + api(project(":nebulosa-stacker")) api(project(":nebulosa-livestacker")) api(libs.bundles.jackson) api(libs.apache.codec) implementation(project(":nebulosa-log")) + testImplementation(project(":nebulosa-image")) testImplementation(project(":nebulosa-test")) } diff --git a/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/livestacker/PixInsightLiveStacker.kt b/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/livestacker/PixInsightLiveStacker.kt index 3348915ff..9a4a12f56 100644 --- a/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/livestacker/PixInsightLiveStacker.kt +++ b/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/livestacker/PixInsightLiveStacker.kt @@ -2,21 +2,24 @@ package nebulosa.pixinsight.livestacker import nebulosa.livestacker.LiveStacker import nebulosa.log.loggerFor -import nebulosa.pixinsight.script.* +import nebulosa.pixinsight.script.PixInsightIsRunning +import nebulosa.pixinsight.script.PixInsightScript +import nebulosa.pixinsight.script.PixInsightScriptRunner +import nebulosa.pixinsight.script.PixInsightStartup +import nebulosa.pixinsight.stacker.PixInsightStacker import java.nio.file.Path import java.util.concurrent.atomic.AtomicBoolean import kotlin.io.path.copyTo import kotlin.io.path.deleteIfExists -import kotlin.io.path.moveTo data class PixInsightLiveStacker( private val runner: PixInsightScriptRunner, private val workingDirectory: Path, - private val dark: Path? = null, - private val flat: Path? = null, - private val bias: Path? = null, + private val darkPath: Path? = null, + private val flatPath: Path? = null, + private val biasPath: Path? = null, private val use32Bits: Boolean = false, - private val slot: Int = PixInsightScript.DEFAULT_SLOT, + private val slot: Int = PixInsightScript.UNSPECIFIED_SLOT, ) : LiveStacker { private val running = AtomicBoolean() @@ -30,17 +33,16 @@ data class PixInsightLiveStacker( @Volatile private var stackCount = 0 + private val stacker = PixInsightStacker(runner, workingDirectory, slot) private val referencePath = Path.of("$workingDirectory", "reference.fits") - private val calibratedPath = Path.of("$workingDirectory", "calibrated.fits") - private val alignedPath = Path.of("$workingDirectory", "aligned.fits") + private val calibratedPath = Path.of("$workingDirectory", "calibrated.xisf") + private val alignedPath = Path.of("$workingDirectory", "aligned.xisf") private val stackedPath = Path.of("$workingDirectory", "stacked.fits") @Synchronized override fun start() { if (!running.get()) { - val isPixInsightRunning = PixInsightIsRunning(slot).use { it.runSync(runner) } - - if (!isPixInsightRunning) { + if (!PixInsightIsRunning(slot).use { it.runSync(runner) }) { try { check(PixInsightStartup(slot).use { it.runSync(runner) }) } catch (e: Throwable) { @@ -60,41 +62,23 @@ data class PixInsightLiveStacker( return if (running.get()) { stacking.set(true) - // Calibrate. - val calibrated = if (dark == null && flat == null && bias == null) false else { - PixInsightCalibrate(slot, workingDirectory, targetPath, dark, flat, if (dark == null) bias else null).use { s -> - val outputPath = s.runSync(runner).outputImage ?: return@use false - LOG.info("live stacking calibrated. count={}, output={}", stackCount, outputPath) - outputPath.moveTo(calibratedPath, true) - true - } - } - - if (calibrated) { + if (stacker.calibrate(targetPath, calibratedPath, darkPath, flatPath, biasPath)) { + LOG.info("live stacking calibrated. count={}, output={}", stackCount, calibratedPath) targetPath = calibratedPath } // TODO: Debayer, Resample? if (stackCount > 0) { - // Align. - val aligned = PixInsightAlign(slot, workingDirectory, referencePath, targetPath).use { s -> - val outputPath = s.runSync(runner).outputImage ?: return@use false - LOG.info("live stacking aligned. count={}, output={}", stackCount, outputPath) - outputPath.moveTo(alignedPath, true) - true - } - - if (aligned) { + if (stacker.align(referencePath, targetPath, alignedPath)) { + LOG.info("live stacking aligned. count={}, output={}", stackCount, alignedPath) targetPath = alignedPath - // Stack. - val expressionRK = "({{0}} * $stackCount + {{1}}) / ${stackCount + 1}" - PixInsightPixelMath(slot, listOf(stackedPath, targetPath), stackedPath, expressionRK).use { s -> - s.runSync(runner).stackedImage?.also { - LOG.info("live stacking finished. count={}, output={}", stackCount++, it) - } + if (stacker.integrate(stackCount, stackedPath, targetPath, stackedPath)) { + LOG.info("live stacking finished. count={}, output={}", stackCount, stackedPath) } + + stackCount++ } } else { targetPath.copyTo(referencePath, true) diff --git a/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/script/AbstractPixInsightScript.kt b/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/script/AbstractPixInsightScript.kt index ad5554d39..f6e746c39 100644 --- a/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/script/AbstractPixInsightScript.kt +++ b/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/script/AbstractPixInsightScript.kt @@ -34,7 +34,7 @@ abstract class AbstractPixInsightScript : PixInsightScript, CommandLineLis if (isDone) return@whenComplete else if (exception != null) completeExceptionally(exception) - else complete(processOnComplete(exitCode).also { LOG.info("script processed. output={}", it) }) + else complete(processOnComplete(exitCode).also { LOG.info("{} script processed. output={}", this::class.simpleName, it) }) } finally { commandLine.unregisterCommandLineListener(this) } @@ -61,7 +61,9 @@ abstract class AbstractPixInsightScript : PixInsightScript, CommandLineLis } @JvmStatic - internal fun execute(slot: Int, scriptPath: Path, data: Any?): String { + internal fun PixInsightScript<*>.execute(slot: Int, scriptPath: Path, data: Any?): String { + LOG.info("{} will be executed. slot={}, script={}, data={}", this::class.simpleName, slot, scriptPath, data) + return buildString { if (slot > 0) append("$slot:") append("\"$scriptPath") diff --git a/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/script/PixInsightAlign.kt b/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/script/PixInsightAlign.kt index 2958d15b0..67e17fc19 100644 --- a/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/script/PixInsightAlign.kt +++ b/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/script/PixInsightAlign.kt @@ -23,8 +23,8 @@ data class PixInsightAlign( ) data class Output( - @JvmField val success: Boolean = false, - @JvmField val errorMessage: String? = null, + override val success: Boolean = false, + override val errorMessage: String? = null, @JvmField val outputImage: Path? = null, @JvmField val outputMaskImage: Path? = null, @JvmField val totalPairMatches: Int = 0, @@ -45,7 +45,7 @@ data class PixInsightAlign( @JvmField val h31: Double = 0.0, @JvmField val h32: Double = 0.0, @JvmField val h33: Double = 0.0, - ) { + ) : PixInsightOutput { companion object { diff --git a/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/script/PixInsightAutomaticBackgroundExtractor.kt b/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/script/PixInsightAutomaticBackgroundExtractor.kt index a68dd5049..ddd3fd64d 100644 --- a/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/script/PixInsightAutomaticBackgroundExtractor.kt +++ b/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/script/PixInsightAutomaticBackgroundExtractor.kt @@ -21,10 +21,10 @@ data class PixInsightAutomaticBackgroundExtractor( ) data class Output( - @JvmField val success: Boolean = false, - @JvmField val errorMessage: String? = null, + override val success: Boolean = false, + override val errorMessage: String? = null, @JvmField val outputImage: Path? = null, - ) { + ) : PixInsightOutput { companion object { diff --git a/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/script/PixInsightCalibrate.kt b/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/script/PixInsightCalibrate.kt index 53cd83351..9cb328be8 100644 --- a/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/script/PixInsightCalibrate.kt +++ b/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/script/PixInsightCalibrate.kt @@ -12,9 +12,9 @@ data class PixInsightCalibrate( private val slot: Int, private val workingDirectory: Path, private val targetPath: Path, - private val dark: Path? = null, - private val flat: Path? = null, - private val bias: Path? = null, + private val darkPath: Path? = null, + private val flatPath: Path? = null, + private val biasPath: Path? = null, private val compress: Boolean = false, private val use32Bit: Boolean = false, ) : AbstractPixInsightScript() { @@ -31,10 +31,10 @@ data class PixInsightCalibrate( ) data class Output( - @JvmField val success: Boolean = false, - @JvmField val errorMessage: String? = null, + override val success: Boolean = false, + override val errorMessage: String? = null, @JvmField val outputImage: Path? = null, - ) { + ) : PixInsightOutput { companion object { @@ -50,7 +50,7 @@ data class PixInsightCalibrate( } override val arguments = - listOf("-x=${execute(slot, scriptPath, Input(targetPath, workingDirectory, statusPath, dark, flat, bias, compress, use32Bit))}") + listOf("-x=${execute(slot, scriptPath, Input(targetPath, workingDirectory, statusPath, darkPath, flatPath, biasPath, compress, use32Bit))}") override fun processOnComplete(exitCode: Int): Output { if (exitCode == 0) { diff --git a/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/script/PixInsightDetectStars.kt b/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/script/PixInsightDetectStars.kt index bfb4952f1..199de03a6 100644 --- a/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/script/PixInsightDetectStars.kt +++ b/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/script/PixInsightDetectStars.kt @@ -26,10 +26,10 @@ data class PixInsightDetectStars( ) data class Output( - @JvmField val success: Boolean = false, - @JvmField val errorMessage: String? = null, + override val success: Boolean = false, + override val errorMessage: String? = null, @JvmField val stars: List = emptyList(), - ) { + ) : PixInsightOutput { override fun toString() = "Output(success=$success, errorMessage=$errorMessage, stars=${stars.size})" diff --git a/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/script/PixInsightFileFormatConversion.kt b/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/script/PixInsightFileFormatConversion.kt new file mode 100644 index 000000000..024b3aff4 --- /dev/null +++ b/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/script/PixInsightFileFormatConversion.kt @@ -0,0 +1,65 @@ +package nebulosa.pixinsight.script + +import nebulosa.io.resource +import nebulosa.io.transferAndClose +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.deleteIfExists +import kotlin.io.path.outputStream +import kotlin.io.path.readText + +data class PixInsightFileFormatConversion( + private val slot: Int, + private val inputPath: Path, + private val outputPath: Path, +) : AbstractPixInsightScript() { + + private data class Input( + @JvmField val inputPath: Path, + @JvmField val outputPath: Path, + @JvmField val statusPath: Path, + ) + + data class Output( + override val success: Boolean = false, + override val errorMessage: String? = null, + @JvmField val outputImage: Path? = null, + ) : PixInsightOutput { + + companion object { + + @JvmStatic val FAILED = Output() + } + } + + private val scriptPath = Files.createTempFile("pi-", ".js") + private val statusPath = Files.createTempFile("pi-", ".txt") + + init { + resource("pixinsight/FileFormatConversion.js")!!.transferAndClose(scriptPath.outputStream()) + } + + override val arguments = + listOf("-x=${execute(slot, scriptPath, Input(inputPath, outputPath, statusPath))}") + + override fun processOnComplete(exitCode: Int): Output { + if (exitCode == 0) { + repeat(30) { + val text = statusPath.readText() + + if (text.startsWith(START_FILE) && text.endsWith(END_FILE)) { + return OBJECT_MAPPER.readValue(text.substring(1, text.length - 1), Output::class.java) + } + + Thread.sleep(1000) + } + } + + return Output.FAILED + } + + override fun close() { + scriptPath.deleteIfExists() + statusPath.deleteIfExists() + } +} diff --git a/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/script/PixInsightHelper.kt b/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/script/PixInsightHelper.kt new file mode 100644 index 000000000..638bf19fe --- /dev/null +++ b/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/script/PixInsightHelper.kt @@ -0,0 +1,15 @@ +package nebulosa.pixinsight.script + +import java.nio.file.Path + +fun startPixInsight(executablePath: Path, slot: Int): PixInsightScriptRunner { + val runner = PixInsightScriptRunner(executablePath) + + if (!PixInsightIsRunning(slot).use { it.runSync(runner) }) { + if (!PixInsightStartup(slot).use { it.runSync(runner) }) { + throw IllegalStateException("unable to start PixInsight") + } + } + + return runner +} diff --git a/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/script/PixInsightLRGBCombination.kt b/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/script/PixInsightLRGBCombination.kt new file mode 100644 index 000000000..9b92c1304 --- /dev/null +++ b/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/script/PixInsightLRGBCombination.kt @@ -0,0 +1,81 @@ +package nebulosa.pixinsight.script + +import nebulosa.io.resource +import nebulosa.io.transferAndClose +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.deleteIfExists +import kotlin.io.path.outputStream +import kotlin.io.path.readText + +@Suppress("ArrayInDataClass") +data class PixInsightLRGBCombination( + private val slot: Int, + private val outputPath: Path, + private val luminancePath: Path? = null, + private val redPath: Path? = null, + private val greenPath: Path? = null, + private val bluePath: Path? = null, + private val weights: DoubleArray = DEFAULT_CHANNEL_WEIGHTS, +) : AbstractPixInsightScript() { + + @Suppress("ArrayInDataClass") + private data class Input( + @JvmField val outputPath: Path, + @JvmField val statusPath: Path, + @JvmField val luminancePath: Path?, + @JvmField val redPath: Path?, + @JvmField val greenPath: Path?, + @JvmField val bluePath: Path?, + @JvmField val channelWeights: DoubleArray, + ) + + data class Output( + override val success: Boolean = false, + override val errorMessage: String? = null, + @JvmField val outputImage: Path? = null, + ) : PixInsightOutput { + + companion object { + + @JvmStatic val FAILED = Output() + } + } + + private val scriptPath = Files.createTempFile("pi-", ".js") + private val statusPath = Files.createTempFile("pi-", ".txt") + + init { + require(weights.size >= 4) { "invalid weights size: ${weights.size}" } + resource("pixinsight/LRGBCombination.js")!!.transferAndClose(scriptPath.outputStream()) + } + + override val arguments = + listOf("-x=${execute(slot, scriptPath, Input(outputPath, statusPath, luminancePath, redPath, greenPath, bluePath, weights))}") + + override fun processOnComplete(exitCode: Int): Output { + if (exitCode == 0) { + repeat(30) { + val text = statusPath.readText() + + if (text.startsWith(START_FILE) && text.endsWith(END_FILE)) { + return OBJECT_MAPPER.readValue(text.substring(1, text.length - 1), Output::class.java) + } + + Thread.sleep(1000) + } + } + + return Output.FAILED + } + + override fun close() { + scriptPath.deleteIfExists() + statusPath.deleteIfExists() + } + + companion object { + + @JvmStatic private val DEFAULT_CHANNEL_WEIGHTS = doubleArrayOf(1.0, 1.0, 1.0, 1.0) + } +} diff --git a/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/script/PixInsightLuminanceCombination.kt b/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/script/PixInsightLuminanceCombination.kt new file mode 100644 index 000000000..74e2a3015 --- /dev/null +++ b/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/script/PixInsightLuminanceCombination.kt @@ -0,0 +1,68 @@ +package nebulosa.pixinsight.script + +import nebulosa.io.resource +import nebulosa.io.transferAndClose +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.deleteIfExists +import kotlin.io.path.outputStream +import kotlin.io.path.readText + +data class PixInsightLuminanceCombination( + private val slot: Int, + private val outputPath: Path, + private val luminancePath: Path, + private val targetPath: Path, +) : AbstractPixInsightScript() { + + private data class Input( + @JvmField val outputPath: Path, + @JvmField val statusPath: Path, + @JvmField val luminancePath: Path, + @JvmField val targetPath: Path, + @JvmField val wWeight: Double, + ) + + data class Output( + override val success: Boolean = false, + override val errorMessage: String? = null, + @JvmField val outputImage: Path? = null, + ) : PixInsightOutput { + + companion object { + + @JvmStatic val FAILED = Output() + } + } + + private val scriptPath = Files.createTempFile("pi-", ".js") + private val statusPath = Files.createTempFile("pi-", ".txt") + + init { + resource("pixinsight/LuminanceCombination.js")!!.transferAndClose(scriptPath.outputStream()) + } + + override val arguments = + listOf("-x=${execute(slot, scriptPath, Input(outputPath, statusPath, luminancePath, targetPath, 1.0))}") + + override fun processOnComplete(exitCode: Int): Output { + if (exitCode == 0) { + repeat(30) { + val text = statusPath.readText() + + if (text.startsWith(START_FILE) && text.endsWith(END_FILE)) { + return OBJECT_MAPPER.readValue(text.substring(1, text.length - 1), Output::class.java) + } + + Thread.sleep(1000) + } + } + + return Output.FAILED + } + + override fun close() { + scriptPath.deleteIfExists() + statusPath.deleteIfExists() + } +} diff --git a/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/script/PixInsightOutput.kt b/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/script/PixInsightOutput.kt new file mode 100644 index 000000000..c4d73143a --- /dev/null +++ b/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/script/PixInsightOutput.kt @@ -0,0 +1,8 @@ +package nebulosa.pixinsight.script + +sealed interface PixInsightOutput { + + val success: Boolean + + val errorMessage: String? +} diff --git a/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/script/PixInsightPixelMath.kt b/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/script/PixInsightPixelMath.kt index a4cd13ad8..7c69ffb33 100644 --- a/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/script/PixInsightPixelMath.kt +++ b/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/script/PixInsightPixelMath.kt @@ -27,10 +27,10 @@ data class PixInsightPixelMath( ) data class Output( - @JvmField val success: Boolean = false, - @JvmField val errorMessage: String? = null, - @JvmField val stackedImage: Path? = null, - ) { + override val success: Boolean = false, + override val errorMessage: String? = null, + @JvmField val outputImage: Path? = null, + ) : PixInsightOutput { companion object { diff --git a/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/stacker/PixInsightAutoStacker.kt b/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/stacker/PixInsightAutoStacker.kt new file mode 100644 index 000000000..8637dca85 --- /dev/null +++ b/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/stacker/PixInsightAutoStacker.kt @@ -0,0 +1,112 @@ +package nebulosa.pixinsight.stacker + +import nebulosa.common.concurrency.cancel.CancellationToken +import nebulosa.pixinsight.script.PixInsightScript +import nebulosa.pixinsight.script.PixInsightScriptRunner +import nebulosa.stacker.AutoStacker +import java.nio.file.Path +import java.util.concurrent.CancellationException +import java.util.concurrent.atomic.AtomicReference +import kotlin.io.path.deleteIfExists + +data class PixInsightAutoStacker( + private val runner: PixInsightScriptRunner, + private val workingDirectory: Path, + private val darkPath: Path? = null, + private val flatPath: Path? = null, + private val biasPath: Path? = null, + private val slot: Int = PixInsightScript.UNSPECIFIED_SLOT, +) : AutoStacker { + + private val cancellationToken = AtomicReference() + private val stacker = PixInsightStacker(runner, workingDirectory, slot) + + override fun stack(paths: Collection, outputPath: Path, referencePath: Path): Boolean { + if (paths.isEmpty()) return false + if (!cancellationToken.compareAndSet(null, CancellationToken())) return false + + val calibratedPath = Path.of("$workingDirectory", "calibrated.xisf") + val alignedPath = Path.of("$workingDirectory", "aligned.xisf") + + try { + var stackCount = 0 + + val realPaths = paths.map { it.toRealPath() } + val referenceRealPath = referencePath.toRealPath() + + realPaths.forEach { + var targetPath = it + + cancellationToken.get().throwIfCancelled() + + if (calibrate(targetPath, calibratedPath, darkPath, flatPath, biasPath)) { + targetPath = calibratedPath + } + + cancellationToken.get().throwIfCancelled() + + if (stackCount > 0) { + if (align(referenceRealPath, targetPath, alignedPath)) { + cancellationToken.get().throwIfCancelled() + integrate(stackCount, outputPath, alignedPath, outputPath) + stackCount++ + } + } else { + if (referenceRealPath != it) { + if (align(referenceRealPath, targetPath, alignedPath)) { + cancellationToken.get().throwIfCancelled() + saveAs(alignedPath, outputPath) + cancellationToken.get().throwIfCancelled() + integrate(0, outputPath, alignedPath, outputPath) + } else { + saveAs(targetPath, outputPath) + } + } else { + saveAs(targetPath, outputPath) + } + + stackCount = 1 + } + + cancellationToken.get().throwIfCancelled() + } + } catch (e: CancellationException) { + return false + } finally { + calibratedPath.deleteIfExists() + alignedPath.deleteIfExists() + + cancellationToken.getAndSet(null) + } + + return true + } + + override fun calibrate(targetPath: Path, outputPath: Path, darkPath: Path?, flatPath: Path?, biasPath: Path?): Boolean { + return stacker.calibrate(targetPath, outputPath, darkPath, flatPath, biasPath) + } + + override fun align(referencePath: Path, targetPath: Path, outputPath: Path): Boolean { + return stacker.align(referencePath, targetPath, outputPath) + } + + override fun integrate(stackCount: Int, stackedPath: Path, targetPath: Path, outputPath: Path): Boolean { + return stacker.integrate(stackCount, stackedPath, targetPath, outputPath) + } + + override fun combineLRGB(outputPath: Path, luminancePath: Path?, redPath: Path?, greenPath: Path?, bluePath: Path?): Boolean { + return stacker.combineLRGB(outputPath, luminancePath, redPath, greenPath, bluePath) + } + + override fun combineLuminance(outputPath: Path, luminancePath: Path, targetPath: Path, mono: Boolean): Boolean { + return stacker.combineLuminance(outputPath, luminancePath, targetPath, mono) + } + + override fun saveAs(inputPath: Path, outputPath: Path): Boolean { + return stacker.saveAs(inputPath, outputPath) + } + + override fun stop() { + cancellationToken.get()?.cancel() + } +} diff --git a/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/stacker/PixInsightStacker.kt b/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/stacker/PixInsightStacker.kt new file mode 100644 index 000000000..91a85b51a --- /dev/null +++ b/nebulosa-pixinsight/src/main/kotlin/nebulosa/pixinsight/stacker/PixInsightStacker.kt @@ -0,0 +1,56 @@ +package nebulosa.pixinsight.stacker + +import nebulosa.pixinsight.script.* +import nebulosa.stacker.Stacker +import java.nio.file.Path +import kotlin.io.path.moveTo + +data class PixInsightStacker( + private val runner: PixInsightScriptRunner, + private val workingDirectory: Path, + private val slot: Int = PixInsightScript.UNSPECIFIED_SLOT, +) : Stacker { + + override fun calibrate( + targetPath: Path, outputPath: Path, + darkPath: Path?, flatPath: Path?, biasPath: Path?, + ) = if (darkPath != null || flatPath != null || biasPath != null) { + PixInsightCalibrate(slot, workingDirectory, targetPath, darkPath, flatPath, if (darkPath == null) biasPath else null) + .use { it.runSync(runner).outputImage?.moveTo(outputPath, true) != null } + } else { + false + } + + override fun align(referencePath: Path, targetPath: Path, outputPath: Path): Boolean { + return PixInsightAlign(slot, workingDirectory, referencePath, targetPath) + .use { it.runSync(runner).outputImage?.moveTo(outputPath, true) != null } + } + + override fun integrate(stackCount: Int, stackedPath: Path, targetPath: Path, outputPath: Path): Boolean { + val expressionRK = "({{0}} * $stackCount + {{1}}) / ${stackCount + 1}" + return PixInsightPixelMath(slot, listOf(stackedPath, targetPath), outputPath, expressionRK) + .use { it.runSync(runner).outputImage != null } + } + + override fun combineLRGB(outputPath: Path, luminancePath: Path?, redPath: Path?, greenPath: Path?, bluePath: Path?): Boolean { + if (luminancePath == null && redPath == null && greenPath == null && bluePath == null) return false + + return PixInsightLRGBCombination(slot, outputPath, luminancePath, redPath, greenPath, bluePath) + .use { it.runSync(runner).outputImage != null } + } + + override fun combineLuminance(outputPath: Path, luminancePath: Path, targetPath: Path, mono: Boolean): Boolean { + return if (mono) { + PixInsightPixelMath(slot, listOf(luminancePath, targetPath), outputPath, "{{0}} + (1 - {{0}}) * {{1}}") + .use { it.runSync(runner).outputImage != null } + } else { + PixInsightLuminanceCombination(slot, outputPath, luminancePath, targetPath) + .use { it.runSync(runner).outputImage != null } + } + } + + override fun saveAs(inputPath: Path, outputPath: Path): Boolean { + return PixInsightFileFormatConversion(slot, inputPath, outputPath) + .use { it.runSync(runner).outputImage != null } + } +} diff --git a/nebulosa-pixinsight/src/main/resources/pixinsight/ABE.js b/nebulosa-pixinsight/src/main/resources/pixinsight/ABE.js index 9ffb6e97c..7fb6fa311 100644 --- a/nebulosa-pixinsight/src/main/resources/pixinsight/ABE.js +++ b/nebulosa-pixinsight/src/main/resources/pixinsight/ABE.js @@ -22,6 +22,7 @@ function abe() { const outputPath = input.outputPath const statusPath = input.statusPath + console.writeln("abe started") console.writeln("targetPath=" + targetPath) console.writeln("outputPath=" + outputPath) console.writeln("statusPath=" + statusPath) diff --git a/nebulosa-pixinsight/src/main/resources/pixinsight/Align.js b/nebulosa-pixinsight/src/main/resources/pixinsight/Align.js index f8f93f38c..1df279c34 100644 --- a/nebulosa-pixinsight/src/main/resources/pixinsight/Align.js +++ b/nebulosa-pixinsight/src/main/resources/pixinsight/Align.js @@ -42,6 +42,7 @@ function alignment() { const outputDirectory = input.outputDirectory const statusPath = input.statusPath + console.writeln("alignment started") console.writeln("referencePath=" + referencePath) console.writeln("targetPath=" + targetPath) console.writeln("outputDirectory=" + outputDirectory) @@ -103,7 +104,7 @@ function alignment() { P.pixelInterpolation = StarAlignment.prototype.Auto P.clampingThreshold = 0.30 P.outputDirectory = outputDirectory - P.outputExtension = ".fits" + P.outputExtension = ".xisf" P.outputPrefix = "" P.outputPostfix = "_a" P.maskPostfix = "_m" diff --git a/nebulosa-pixinsight/src/main/resources/pixinsight/Calibrate.js b/nebulosa-pixinsight/src/main/resources/pixinsight/Calibrate.js index 814586b86..a4290b64d 100644 --- a/nebulosa-pixinsight/src/main/resources/pixinsight/Calibrate.js +++ b/nebulosa-pixinsight/src/main/resources/pixinsight/Calibrate.js @@ -27,6 +27,7 @@ function calibrate() { const compress = input.compress const use32Bit = input.use32Bit + console.writeln("calibration started") console.writeln("targetPath=" + targetPath) console.writeln("outputDirectory=" + outputDirectory) console.writeln("statusPath=" + statusPath) @@ -89,7 +90,7 @@ function calibrate() { P.psfGrowth = 1.00 P.maxStars = 24576 P.outputDirectory = outputDirectory - P.outputExtension = ".fits" + P.outputExtension = ".xisf" P.outputPrefix = "" P.outputPostfix = "_c" P.outputSampleFormat = use32Bit ? ImageCalibration.prototype.f32 : ImageCalibration.prototype.i16 diff --git a/nebulosa-pixinsight/src/main/resources/pixinsight/FileFormatConversion.js b/nebulosa-pixinsight/src/main/resources/pixinsight/FileFormatConversion.js new file mode 100644 index 000000000..060a1b4bd --- /dev/null +++ b/nebulosa-pixinsight/src/main/resources/pixinsight/FileFormatConversion.js @@ -0,0 +1,46 @@ +function decodeParams(hex) { + const buffer = new Uint8Array(hex.length / 4) + + for (let i = 0; i < hex.length; i += 4) { + buffer[i / 4] = parseInt(hex.substr(i, 4), 16) + } + + return JSON.parse(String.fromCharCode.apply(null, buffer)) +} + +function fileFormatConversion() { + const data = { + success: true, + errorMessage: null, + outputImage: null, + } + + try { + const input = decodeParams(jsArguments[0]) + + const outputPath = input.outputPath + const statusPath = input.statusPath + const inputPath = input.inputPath + + console.writeln("Format conversion started") + console.writeln("outputPath=" + outputPath) + console.writeln("statusPath=" + statusPath) + console.writeln("inputPath=" + inputPath) + + const window = ImageWindow.open(inputPath)[0] + window.saveAs(outputPath, false, false, false, false) + window.forceClose() + + data.outputImage = outputPath + + console.writeln("Format conversion finished") + } catch (e) { + data.success = false + data.errorMessage = e.message + console.writeln(data.errorMessage) + } finally { + File.writeTextFile(statusPath, "@" + JSON.stringify(data) + "#") + } +} + +fileFormatConversion() diff --git a/nebulosa-pixinsight/src/main/resources/pixinsight/LRGBCombination.js b/nebulosa-pixinsight/src/main/resources/pixinsight/LRGBCombination.js new file mode 100644 index 000000000..455d9b6ab --- /dev/null +++ b/nebulosa-pixinsight/src/main/resources/pixinsight/LRGBCombination.js @@ -0,0 +1,81 @@ +function decodeParams(hex) { + const buffer = new Uint8Array(hex.length / 4) + + for (let i = 0; i < hex.length; i += 4) { + buffer[i / 4] = parseInt(hex.substr(i, 4), 16) + } + + return JSON.parse(String.fromCharCode.apply(null, buffer)) +} + +function lrgbCombination() { + const data = { + success: true, + errorMessage: null, + outputImage: null, + } + + try { + const input = decodeParams(jsArguments[0]) + + const outputPath = input.outputPath + const statusPath = input.statusPath + const channelWeights = input.channelWeights + const luminancePath = input.luminancePath + const redPath = input.redPath + const greenPath = input.greenPath + const bluePath = input.bluePath + + console.writeln("LRGB combination started") + console.writeln("outputPath=" + outputPath) + console.writeln("statusPath=" + statusPath) + console.writeln("channelWeights=" + channelWeights) + console.writeln("luminancePath=" + luminancePath) + console.writeln("redPath=" + redPath) + console.writeln("greenPath=" + greenPath) + console.writeln("bluePath=" + bluePath) + + const luminanceWindow = luminancePath ? ImageWindow.open(luminancePath)[0] : undefined + const redWindow = redPath ? ImageWindow.open(redPath)[0] : undefined + const greenWindow = greenPath ? ImageWindow.open(greenPath)[0] : undefined + const blueWindow = bluePath ? ImageWindow.open(bluePath)[0] : undefined + + var P = new LRGBCombination + P.channels = [ // enabled, id, k + [!!redPath, redWindow ? redWindow.mainView.id : "", channelWeights[1]], + [!!greenPath, greenWindow ? greenWindow.mainView.id : "", channelWeights[2]], + [!!bluePath, blueWindow ? blueWindow.mainView.id : "", channelWeights[3]], + [!!luminancePath, luminanceWindow ? luminanceWindow.mainView.id : "", channelWeights[0]] + ] + P.mL = 0.500 + P.mc = 0.500 + P.clipHighlights = true + P.noiseReduction = false + P.layersRemoved = 4 + P.layersProtected = 2 + P.inheritAstrometricSolution = true + + P.executeGlobal() + + const window = ImageWindow.windows[ImageWindow.windows.length - 1] + window.saveAs(outputPath, false, false, false, false) + window.forceClose() + + if (luminanceWindow) luminanceWindow.forceClose() + if (redWindow) redWindow.forceClose() + if (greenWindow) greenWindow.forceClose() + if (blueWindow) blueWindow.forceClose() + + data.outputImage = outputPath + + console.writeln("LRGB combination finished") + } catch (e) { + data.success = false + data.errorMessage = e.message + console.writeln(data.errorMessage) + } finally { + File.writeTextFile(statusPath, "@" + JSON.stringify(data) + "#") + } +} + +lrgbCombination() diff --git a/nebulosa-pixinsight/src/main/resources/pixinsight/LuminanceCombination.js b/nebulosa-pixinsight/src/main/resources/pixinsight/LuminanceCombination.js new file mode 100644 index 000000000..e70684726 --- /dev/null +++ b/nebulosa-pixinsight/src/main/resources/pixinsight/LuminanceCombination.js @@ -0,0 +1,71 @@ +function decodeParams(hex) { + const buffer = new Uint8Array(hex.length / 4) + + for (let i = 0; i < hex.length; i += 4) { + buffer[i / 4] = parseInt(hex.substr(i, 4), 16) + } + + return JSON.parse(String.fromCharCode.apply(null, buffer)) +} + +function luminanceCombination() { + const data = { + success: true, + errorMessage: null, + outputImage: null, + } + + try { + const input = decodeParams(jsArguments[0]) + + const outputPath = input.outputPath + const statusPath = input.statusPath + const weight = input.weight + const luminancePath = input.luminancePath + const targetPath = input.targetPath + + console.writeln("Luminance combination started") + console.writeln("outputPath=" + outputPath) + console.writeln("statusPath=" + statusPath) + console.writeln("weight=" + weight) + console.writeln("luminancePath=" + luminancePath) + console.writeln("targetPath=" + targetPath) + + const luminanceWindow = luminancePath ? ImageWindow.open(luminancePath)[0] : undefined + const targetWindow = targetPath ? ImageWindow.open(targetPath)[0] : undefined + + var P = new LRGBCombination + P.channels = [ // enabled, id, k + [false, "", 1.0], + [false, "", 1.0], + [false, "", 1.0], + [true, luminanceWindow.mainView.id, weight] + ] + P.mL = 0.500 + P.mc = 0.500 + P.clipHighlights = true + P.noiseReduction = false + P.layersRemoved = 4 + P.layersProtected = 2 + P.inheritAstrometricSolution = true + + P.executeOn(targetWindow.mainView) + + targetWindow.saveAs(outputPath, false, false, false, false) + window.forceClose() + + luminanceWindow.forceClose() + + data.outputImage = outputPath + + console.writeln("Luminance combination finished") + } catch (e) { + data.success = false + data.errorMessage = e.message + console.writeln(data.errorMessage) + } finally { + File.writeTextFile(statusPath, "@" + JSON.stringify(data) + "#") + } +} + +luminanceCombination() diff --git a/nebulosa-pixinsight/src/main/resources/pixinsight/PixelMath.js b/nebulosa-pixinsight/src/main/resources/pixinsight/PixelMath.js index 7c335ff77..b3ad983a2 100644 --- a/nebulosa-pixinsight/src/main/resources/pixinsight/PixelMath.js +++ b/nebulosa-pixinsight/src/main/resources/pixinsight/PixelMath.js @@ -12,7 +12,7 @@ function pixelMath() { const data = { success: true, errorMessage: null, - stackedImage: null, + outputImage: null, } try { @@ -47,6 +47,7 @@ function pixelMath() { } } + console.writeln("pixel math started") console.writeln("expressionRK=" + expressionRK) console.writeln("expressionG=" + expressionG) console.writeln("expressionB=" + expressionB) @@ -87,9 +88,9 @@ function pixelMath() { windows[i].forceClose() } - data.stackedImage = outputPath + data.outputImage = outputPath - console.writeln("stacking finished") + console.writeln("pixel math finished") } catch (e) { data.success = false data.errorMessage = e.message diff --git a/nebulosa-pixinsight/src/test/kotlin/PixInsightAutoStackerTest.kt b/nebulosa-pixinsight/src/test/kotlin/PixInsightAutoStackerTest.kt new file mode 100644 index 000000000..e4c6a0feb --- /dev/null +++ b/nebulosa-pixinsight/src/test/kotlin/PixInsightAutoStackerTest.kt @@ -0,0 +1,42 @@ +import PixInsightScriptTest.Companion.openAsImage +import io.kotest.core.annotation.EnabledIf +import io.kotest.engine.spec.tempdir +import io.kotest.engine.spec.tempfile +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.shouldBe +import nebulosa.image.algorithms.transformation.AutoScreenTransformFunction +import nebulosa.pixinsight.script.PixInsightScriptRunner +import nebulosa.pixinsight.stacker.PixInsightAutoStacker +import nebulosa.test.AbstractFitsAndXisfTest +import nebulosa.test.NonGitHubOnlyCondition +import java.nio.file.Path + +@EnabledIf(NonGitHubOnlyCondition::class) +class PixInsightAutoStackerTest : AbstractFitsAndXisfTest() { + + init { + val runner = PixInsightScriptRunner(Path.of("PixInsight")) + val workingDirectory = tempdir("pi-").toPath() + + "stack" { + val files = listOf(PI_01_LIGHT, PI_02_LIGHT, PI_03_LIGHT, PI_04_LIGHT, PI_05_LIGHT, PI_06_LIGHT, PI_07_LIGHT, PI_08_LIGHT) + val outputPath = tempfile("pi-", ".fits").toPath() + + val stacker = PixInsightAutoStacker(runner, workingDirectory) + stacker.stack(files, outputPath).shouldBeTrue() + + outputPath.openAsImage().transform(AutoScreenTransformFunction) + .save("pi-auto-stacked").second shouldBe "a107143dff3d43c4b56c872da869f89b" + } + "!calibrated stack" { + val files = listOf(PI_01_LIGHT, PI_02_LIGHT, PI_03_LIGHT, PI_04_LIGHT, PI_05_LIGHT, PI_06_LIGHT, PI_07_LIGHT, PI_08_LIGHT) + val outputPath = tempfile("pi-", ".fits").toPath() + + val stacker = PixInsightAutoStacker(runner, workingDirectory, PI_DARK, PI_FLAT, PI_BIAS) + stacker.stack(files, outputPath).shouldBeTrue() + + outputPath.openAsImage().transform(AutoScreenTransformFunction) + .save("pi-calibrated-auto-stacked").second shouldBe "" + } + } +} diff --git a/nebulosa-pixinsight/src/test/kotlin/PixInsightScriptTest.kt b/nebulosa-pixinsight/src/test/kotlin/PixInsightScriptTest.kt index 2bc11d4d2..cf1ed2a92 100644 --- a/nebulosa-pixinsight/src/test/kotlin/PixInsightScriptTest.kt +++ b/nebulosa-pixinsight/src/test/kotlin/PixInsightScriptTest.kt @@ -4,12 +4,17 @@ import io.kotest.engine.spec.tempfile import io.kotest.matchers.booleans.shouldBeTrue import io.kotest.matchers.doubles.plusOrMinus import io.kotest.matchers.nulls.shouldNotBeNull -import io.kotest.matchers.paths.shouldExist import io.kotest.matchers.shouldBe +import nebulosa.fits.fits +import nebulosa.fits.isFits +import nebulosa.image.Image +import nebulosa.image.algorithms.transformation.AutoScreenTransformFunction import nebulosa.pixinsight.script.* +import nebulosa.pixinsight.script.PixInsightScript.Companion.UNSPECIFIED_SLOT import nebulosa.test.AbstractFitsAndXisfTest import nebulosa.test.NonGitHubOnlyCondition -import java.nio.file.Files +import nebulosa.xisf.isXisf +import nebulosa.xisf.xisf import java.nio.file.Path @EnabledIf(NonGitHubOnlyCondition::class) @@ -19,47 +24,117 @@ class PixInsightScriptTest : AbstractFitsAndXisfTest() { val runner = PixInsightScriptRunner(Path.of("PixInsight")) val workingDirectory = tempdir("pi-").toPath() - "startup" { + "!startup" { PixInsightStartup(PixInsightScript.DEFAULT_SLOT) .use { it.runSync(runner).shouldBeTrue() } } - "is running" { + "!is running" { PixInsightIsRunning(PixInsightScript.DEFAULT_SLOT) .use { it.runSync(runner).shouldBeTrue() } } "calibrate" { - PixInsightCalibrate(PixInsightScript.UNSPECIFIED_SLOT, workingDirectory, PI_01_LIGHT, PI_DARK, PI_FLAT, PI_BIAS) - .use { it.runSync(runner).also(::println).outputImage.shouldNotBeNull().shouldExist() } + PixInsightCalibrate(UNSPECIFIED_SLOT, workingDirectory, PI_01_LIGHT, PI_DARK, PI_FLAT, PI_BIAS) + .use { it.runSync(runner).outputImage.shouldNotBeNull().openAsImage() } + .transform(AutoScreenTransformFunction).save("pi-calibrate").second shouldBe "731562ee12f45bf7c1095f4773f70e71" } "align" { - PixInsightAlign(PixInsightScript.UNSPECIFIED_SLOT, workingDirectory, PI_01_LIGHT, PI_02_LIGHT) - .use { it.runSync(runner).also(::println).outputImage.shouldNotBeNull().shouldExist() } + PixInsightAlign(UNSPECIFIED_SLOT, workingDirectory, PI_01_LIGHT, PI_02_LIGHT) + .use { it.runSync(runner).outputImage.shouldNotBeNull().openAsImage() } + .transform(AutoScreenTransformFunction).save("pi-align").second shouldBe "483ebaf15afa5957fe099f3ee2beff78" } "detect stars" { - PixInsightDetectStars(PixInsightScript.UNSPECIFIED_SLOT, PI_FOCUS_0) - .use { it.runSync(runner).also(::println).stars } + PixInsightDetectStars(UNSPECIFIED_SLOT, PI_FOCUS_0) + .use { it.runSync(runner).stars } .map { it.hfd } .average() shouldBe (8.43 plusOrMinus 1e-2) - PixInsightDetectStars(PixInsightScript.UNSPECIFIED_SLOT, PI_FOCUS_30000) - .use { it.runSync(runner).also(::println).stars } + PixInsightDetectStars(UNSPECIFIED_SLOT, PI_FOCUS_30000) + .use { it.runSync(runner).stars } .map { it.hfd } .average() shouldBe (1.85 plusOrMinus 1e-2) - PixInsightDetectStars(PixInsightScript.UNSPECIFIED_SLOT, PI_FOCUS_100000) - .use { it.runSync(runner).also(::println).stars } + PixInsightDetectStars(UNSPECIFIED_SLOT, PI_FOCUS_100000) + .use { it.runSync(runner).stars } .map { it.hfd } .average() shouldBe (18.35 plusOrMinus 1e-2) } "pixel math" { - val outputPath = Files.createTempFile("pi-stacked-", ".fits") - PixInsightPixelMath(PixInsightScript.UNSPECIFIED_SLOT, listOf(PI_01_LIGHT, PI_02_LIGHT), outputPath, "{{0}} + {{1}}") - .use { it.runSync(runner).also(::println).stackedImage.shouldNotBeNull().shouldExist() } + val outputPath = tempfile("pi-stacked-", ".fits").toPath() + PixInsightPixelMath(UNSPECIFIED_SLOT, listOf(PI_01_LIGHT, PI_02_LIGHT), outputPath, "{{0}} + {{1}}") + .use { it.runSync(runner).outputImage.shouldNotBeNull().openAsImage() } + .transform(AutoScreenTransformFunction).save("pi-pixelmath").second shouldBe "cafc8138e2ce17614dcfa10edf410b07" } "abe" { - val outputPath = tempfile("pi-", ".fits").toPath() - PixInsightAutomaticBackgroundExtractor(PixInsightScript.UNSPECIFIED_SLOT, PI_01_LIGHT, outputPath) - .use { it.runSync(runner).also(::println).outputImage.shouldNotBeNull() } + val outputPath = tempfile("pi-abe-", ".fits").toPath() + PixInsightAutomaticBackgroundExtractor(UNSPECIFIED_SLOT, PI_01_LIGHT, outputPath) + .use { it.runSync(runner).outputImage.shouldNotBeNull().openAsImage() } + .transform(AutoScreenTransformFunction).save("pi-abe").second shouldBe "bf62207dc17190009ba215da7c011297" + } + "lrgb combination" { + val outputPath = tempfile("pi-lrgb-", ".fits").toPath() + PixInsightLRGBCombination(UNSPECIFIED_SLOT, outputPath, PI_01_LIGHT, PI_01_LIGHT, PI_01_LIGHT, PI_01_LIGHT) + .use { it.runSync(runner).outputImage.shouldNotBeNull().openAsImage() } + .transform(AutoScreenTransformFunction).save("pi-lrgb").second shouldBe "99db35d78f7b360e7592217f4179b189" + + val weights = doubleArrayOf(1.0, 0.2470588, 0.31764705, 0.709803921) // LRGB #3F51B5 + PixInsightLRGBCombination(UNSPECIFIED_SLOT, outputPath, PI_01_LIGHT, PI_01_LIGHT, PI_01_LIGHT, PI_01_LIGHT, weights) + .use { it.runSync(runner).outputImage.shouldNotBeNull().openAsImage() } + .transform(AutoScreenTransformFunction).save("pi-weighted-lrgb").second shouldBe "1148ee222fbfb382ad2d708df5b0f79f" + + PixInsightLRGBCombination(UNSPECIFIED_SLOT, outputPath, PI_01_LIGHT, PI_01_LIGHT, null, null) + .use { it.runSync(runner).outputImage.shouldNotBeNull().openAsImage() } + .transform(AutoScreenTransformFunction).save("pi-lr").second shouldBe "9100d3ce892f05f4b832b2fb5f35b5a1" + + PixInsightLRGBCombination(UNSPECIFIED_SLOT, outputPath, PI_01_LIGHT, null, PI_01_LIGHT, null) + .use { it.runSync(runner).outputImage.shouldNotBeNull().openAsImage() } + .transform(AutoScreenTransformFunction).save("pi-lg").second shouldBe "b4e8d8f7e289db60b41ba2bbe0035344" + + PixInsightLRGBCombination(UNSPECIFIED_SLOT, outputPath, PI_01_LIGHT, null, null, PI_01_LIGHT) + .use { it.runSync(runner).outputImage.shouldNotBeNull().openAsImage() } + .transform(AutoScreenTransformFunction).save("pi-lb").second shouldBe "1760e7cb1d139b63022dd975fe84897d" + + PixInsightLRGBCombination(UNSPECIFIED_SLOT, outputPath, null, PI_01_LIGHT, PI_01_LIGHT, null) + .use { it.runSync(runner).outputImage.shouldNotBeNull().openAsImage() } + .transform(AutoScreenTransformFunction).save("pi-rg").second shouldBe "8c59307b5943932aefdf2dedfe1c8178" + + PixInsightLRGBCombination(UNSPECIFIED_SLOT, outputPath, null, PI_01_LIGHT, null, PI_01_LIGHT) + .use { it.runSync(runner).outputImage.shouldNotBeNull().openAsImage() } + .transform(AutoScreenTransformFunction).save("pi-rb").second shouldBe "1bdf9cada6a33f76dceaccdaacf30fef" + + PixInsightLRGBCombination(UNSPECIFIED_SLOT, outputPath, null, null, PI_01_LIGHT, PI_01_LIGHT) + .use { it.runSync(runner).outputImage.shouldNotBeNull().openAsImage() } + .transform(AutoScreenTransformFunction).save("pi-bg").second shouldBe "4a9c81c71fd37546fd300d1037742fa2" + + PixInsightLRGBCombination(UNSPECIFIED_SLOT, outputPath, PI_01_LIGHT, PI_01_LIGHT, PI_01_LIGHT, null) + .use { it.runSync(runner).outputImage.shouldNotBeNull().openAsImage() } + .transform(AutoScreenTransformFunction).save("pi-lrg").second shouldBe "06c32c8679d409302423baa3a07fb241" + + PixInsightLRGBCombination(UNSPECIFIED_SLOT, outputPath, PI_01_LIGHT, PI_01_LIGHT, null, PI_01_LIGHT) + .use { it.runSync(runner).outputImage.shouldNotBeNull().openAsImage() } + .transform(AutoScreenTransformFunction).save("pi-lrb").second shouldBe "f6d026cb63f7a58fc325e422c277ff89" + + PixInsightLRGBCombination(UNSPECIFIED_SLOT, outputPath, PI_01_LIGHT, null, PI_01_LIGHT, PI_01_LIGHT) + .use { it.runSync(runner).outputImage.shouldNotBeNull().openAsImage() } + .transform(AutoScreenTransformFunction).save("pi-lbg").second shouldBe "67f961110fb4b9f0033b3b8dbc8b1638" + } + "file format conversion" { + val xisfPath = tempfile("pi-ffc", ".xisf").toPath() + PixInsightFileFormatConversion(UNSPECIFIED_SLOT, PI_01_LIGHT, xisfPath) + .use { it.runSync(runner).outputImage.shouldNotBeNull().isXisf().shouldBeTrue() } + + val fitsPath = tempfile("pi-ffc", ".fits").toPath() + PixInsightFileFormatConversion(UNSPECIFIED_SLOT, xisfPath, fitsPath) + .use { it.runSync(runner).outputImage.shouldNotBeNull().isFits().shouldBeTrue() } + } + } + + companion object { + + @JvmStatic + internal fun Path.openAsImage(): Image { + return if (isFits()) fits().use(Image::open) + else if (isXisf()) xisf().use(Image::open) + else throw IllegalArgumentException("the path at $this is not an image") } } } diff --git a/nebulosa-pixinsight/src/test/kotlin/PixInsightStackerTest.kt b/nebulosa-pixinsight/src/test/kotlin/PixInsightStackerTest.kt new file mode 100644 index 000000000..1a8574bb2 --- /dev/null +++ b/nebulosa-pixinsight/src/test/kotlin/PixInsightStackerTest.kt @@ -0,0 +1,58 @@ +import PixInsightScriptTest.Companion.openAsImage +import io.kotest.core.annotation.EnabledIf +import io.kotest.engine.spec.tempdir +import io.kotest.engine.spec.tempfile +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.shouldBe +import nebulosa.image.algorithms.transformation.AutoScreenTransformFunction +import nebulosa.pixinsight.script.PixInsightScriptRunner +import nebulosa.pixinsight.stacker.PixInsightStacker +import nebulosa.test.AbstractFitsAndXisfTest +import nebulosa.test.NonGitHubOnlyCondition +import java.nio.file.Path + +@EnabledIf(NonGitHubOnlyCondition::class) +class PixInsightStackerTest : AbstractFitsAndXisfTest() { + + init { + val runner = PixInsightScriptRunner(Path.of("PixInsight")) + val workingDirectory = tempdir("pi-").toPath() + val stacker = PixInsightStacker(runner, workingDirectory) + + "align" { + val outputPath = tempfile("pi-", ".fits").toPath() + stacker.align(PI_01_LIGHT, PI_03_LIGHT, outputPath).shouldBeTrue() + + outputPath.openAsImage().transform(AutoScreenTransformFunction) + .save("pi-aligned").second shouldBe "106651a7c1e640852384284ec12e0977" + } + "calibrate" { + val outputPath = tempfile("pi-", ".fits").toPath() + stacker.calibrate(PI_01_LIGHT, outputPath, PI_DARK).shouldBeTrue() + + outputPath.openAsImage().transform(AutoScreenTransformFunction) + .save("pi-calibrated").second shouldBe "8f5a2632c701680b41fcfe170c9cf468" + } + "integrate" { + val outputPath = tempfile("pi-", ".fits").toPath() + stacker.integrate(1, PI_01_LIGHT, PI_01_LIGHT, outputPath).shouldBeTrue() + + outputPath.openAsImage().transform(AutoScreenTransformFunction) + .save("pi-integrated").second shouldBe "bf62207dc17190009ba215da7c011297" + } + "combine LRGB" { + val outputPath = tempfile("pi-", ".fits").toPath() + stacker.combineLRGB(outputPath, PI_01_LIGHT, PI_01_LIGHT).shouldBeTrue() + + outputPath.openAsImage().transform(AutoScreenTransformFunction) + .save("pi-lrgb-combined").second shouldBe "9100d3ce892f05f4b832b2fb5f35b5a1" + } + "combine mono luminance" { + val outputPath = tempfile("pi-", ".fits").toPath() + stacker.combineLuminance(outputPath, PI_01_LIGHT, PI_01_LIGHT, true).shouldBeTrue() + + outputPath.openAsImage().transform(AutoScreenTransformFunction) + .save("pi-mono-luminance-combined").second shouldBe "85de365a9895234222acdc6e9feb7009" + } + } +} diff --git a/nebulosa-siril/src/main/kotlin/nebulosa/siril/command/StartLs.kt b/nebulosa-siril/src/main/kotlin/nebulosa/siril/command/StartLs.kt index 716b47754..85afd66af 100644 --- a/nebulosa-siril/src/main/kotlin/nebulosa/siril/command/StartLs.kt +++ b/nebulosa-siril/src/main/kotlin/nebulosa/siril/command/StartLs.kt @@ -12,16 +12,16 @@ import kotlin.io.path.isRegularFile * Initializes a livestacking session. */ data class StartLs( - @JvmField val dark: Path? = null, - @JvmField val flat: Path? = null, + @JvmField val darkPath: Path? = null, + @JvmField val flatPath: Path? = null, @JvmField val use32Bits: Boolean = false, ) : SirilCommand, CommandLineListener { private val command by lazy { buildString(256) { append("start_ls") - if (dark != null && dark.exists() && dark.isRegularFile()) append(" \"-dark=$dark\"") - if (flat != null && flat.exists() && flat.isRegularFile()) append(" \"-flat=$flat\"") + if (darkPath != null && darkPath.exists() && darkPath.isRegularFile()) append(" \"-dark=$darkPath\"") + if (flatPath != null && flatPath.exists() && flatPath.isRegularFile()) append(" \"-flat=$flatPath\"") if (use32Bits) append(" -32bits") } } diff --git a/nebulosa-siril/src/main/kotlin/nebulosa/siril/livestacker/SirilLiveStacker.kt b/nebulosa-siril/src/main/kotlin/nebulosa/siril/livestacker/SirilLiveStacker.kt index f8617f95b..a1e421fef 100644 --- a/nebulosa-siril/src/main/kotlin/nebulosa/siril/livestacker/SirilLiveStacker.kt +++ b/nebulosa-siril/src/main/kotlin/nebulosa/siril/livestacker/SirilLiveStacker.kt @@ -14,8 +14,8 @@ import kotlin.io.path.name data class SirilLiveStacker( private val executablePath: Path, private val workingDirectory: Path, - private val dark: Path? = null, - private val flat: Path? = null, + private val darkPath: Path? = null, + private val flatPath: Path? = null, private val use32Bits: Boolean = false, ) : LiveStacker, CommandLineListener { @@ -38,7 +38,7 @@ data class SirilLiveStacker( try { check(commandLine.execute(Cd(workingDirectory))) { "failed to run cd command" } - check(commandLine.execute(StartLs(dark, flat, use32Bits))) { "failed to start livestacking" } + check(commandLine.execute(StartLs(darkPath, flatPath, use32Bits))) { "failed to start livestacking" } } catch (e: Throwable) { commandLine.close() throw e diff --git a/nebulosa-stacker/build.gradle.kts b/nebulosa-stacker/build.gradle.kts new file mode 100644 index 000000000..4d1b2976b --- /dev/null +++ b/nebulosa-stacker/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + kotlin("jvm") + id("maven-publish") +} + +dependencies { + api(libs.logback) +} + +publishing { + publications { + create("pluginMaven") { + from(components["java"]) + } + } +} diff --git a/nebulosa-stacker/src/main/kotlin/nebulosa/stacker/AutoStacker.kt b/nebulosa-stacker/src/main/kotlin/nebulosa/stacker/AutoStacker.kt new file mode 100644 index 000000000..d13fa3fc5 --- /dev/null +++ b/nebulosa-stacker/src/main/kotlin/nebulosa/stacker/AutoStacker.kt @@ -0,0 +1,10 @@ +package nebulosa.stacker + +import java.nio.file.Path + +interface AutoStacker : Stacker { + + fun stack(paths: Collection, outputPath: Path, referencePath: Path = paths.first()): Boolean + + fun stop() +} diff --git a/nebulosa-stacker/src/main/kotlin/nebulosa/stacker/Stacker.kt b/nebulosa-stacker/src/main/kotlin/nebulosa/stacker/Stacker.kt new file mode 100644 index 000000000..31cf7f719 --- /dev/null +++ b/nebulosa-stacker/src/main/kotlin/nebulosa/stacker/Stacker.kt @@ -0,0 +1,21 @@ +package nebulosa.stacker + +import java.nio.file.Path + +interface Stacker { + + fun calibrate( + targetPath: Path, outputPath: Path, + darkPath: Path? = null, flatPath: Path? = null, biasPath: Path? = null, + ): Boolean + + fun align(referencePath: Path, targetPath: Path, outputPath: Path): Boolean + + fun integrate(stackCount: Int, stackedPath: Path, targetPath: Path, outputPath: Path): Boolean + + fun combineLRGB(outputPath: Path, luminancePath: Path? = null, redPath: Path? = null, greenPath: Path? = null, bluePath: Path? = null): Boolean + + fun combineLuminance(outputPath: Path, luminancePath: Path, targetPath: Path, mono: Boolean): Boolean + + fun saveAs(inputPath: Path, outputPath: Path): Boolean +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 3e9c051c9..95058fe59 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -88,6 +88,7 @@ include(":nebulosa-skycatalog-hyg") include(":nebulosa-skycatalog-sao") include(":nebulosa-skycatalog-stellarium") include(":nebulosa-stardetector") +include(":nebulosa-stacker") include(":nebulosa-stellarium-protocol") include(":nebulosa-test") include(":nebulosa-time")