Skip to content

Commit

Permalink
[api]: Support Auto Focus
Browse files Browse the repository at this point in the history
  • Loading branch information
tiagohm committed May 26, 2024
1 parent 67e0cf4 commit 860b3ac
Show file tree
Hide file tree
Showing 8 changed files with 108 additions and 42 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,7 @@ package nebulosa.api.autofocus

import nebulosa.indi.device.camera.Camera
import nebulosa.indi.device.focuser.Focuser
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.bind.annotation.*

@RestController
@RequestMapping("auto-focus")
Expand All @@ -16,4 +13,10 @@ class AutoFocusController(private val autoFocusService: AutoFocusService) {
camera: Camera, focuser: Focuser,
@RequestBody body: AutoFocusRequest,
) = autoFocusService.start(camera, focuser, body)

@PutMapping("{camera}/stop")
fun stop(camera: Camera) = autoFocusService.stop(camera)

@GetMapping("{camera}/status")
fun status(camera: Camera) = autoFocusService.status(camera)
}
8 changes: 7 additions & 1 deletion api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusEvent.kt
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
package nebulosa.api.autofocus

import nebulosa.api.cameras.CameraCaptureEvent
import nebulosa.api.messages.MessageEvent
import nebulosa.curve.fitting.CurvePoint

class AutoFocusEvent : MessageEvent {
data class AutoFocusEvent(
@JvmField val state: AutoFocusState = AutoFocusState.IDLE,
@JvmField val focusPoint: CurvePoint = CurvePoint.ZERO,
@JvmField val capture: CameraCaptureEvent? = null,
) : MessageEvent {

override val eventName = "AUTO_FOCUS.ELAPSED"
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,12 @@ class AutoFocusService(
fun start(camera: Camera, focuser: Focuser, body: AutoFocusRequest) {
autoFocusExecutor.execute(camera, focuser, body)
}

fun stop(camera: Camera) {
autoFocusExecutor.stop(camera)
}

fun status(camera: Camera): AutoFocusEvent? {
return autoFocusExecutor.status(camera)
}
}
11 changes: 11 additions & 0 deletions api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusState.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package nebulosa.api.autofocus

enum class AutoFocusState {
IDLE,
MOVING,
EXPOSURING,
COMPUTING,
FOCUS_POINT_ADDED,
FAILED,
FINISHED,
}
78 changes: 51 additions & 27 deletions api/src/main/kotlin/nebulosa/api/autofocus/AutoFocusTask.kt
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ data class AutoFocusTask(

data class MeasuredStars(
@JvmField val averageHFD: Double = 0.0,
@JvmField var hfdStandardDeviation: Double = 0.0,
@JvmField val hfdStandardDeviation: Double = 0.0,
) {

companion object {
Expand All @@ -58,13 +58,15 @@ data class AutoFocusTask(

private val focusPoints = ArrayList<CurvePoint>()
private val measurements = ArrayList<MeasuredStars>(request.capture.exposureAmount)
private val cameraCaptureTask = CameraCaptureTask(camera, cameraRequest, exposureMaxRepeat = request.capture.exposureAmount)
private val cameraCaptureTask = CameraCaptureTask(camera, cameraRequest, exposureMaxRepeat = max(1, request.capture.exposureAmount))

@Volatile private var focuserMoveTask: FocuserMoveTask? = null
@Volatile private var trendLineCurve: TrendLineFitting.Curve? = null
@Volatile private var parabolicCurve: Lazy<QuadraticFitting.Curve>? = null
@Volatile private var hyperbolicCurve: Lazy<HyperbolicFitting.Curve>? = null

@Volatile private var focusPoint = CurvePoint.ZERO

init {
cameraCaptureTask.subscribe(this)
}
Expand All @@ -80,6 +82,8 @@ data class AutoFocusTask(
override fun canUseAsLastEvent(event: MessageEvent) = event is AutoFocusEvent

override fun execute(cancellationToken: CancellationToken) {
reset()

val initialFocusPosition = focuser.position

// Get initial position information, as average of multiple exposures, if configured this way.
Expand All @@ -92,6 +96,8 @@ data class AutoFocusTask(
var numberOfAttempts = 0
val maximumFocusPoints = request.capture.exposureAmount * request.initialOffsetSteps * 10

camera.snoop(listOf(focuser))

while (!exited && !cancellationToken.isCancelled) {
numberOfAttempts++

Expand Down Expand Up @@ -153,9 +159,9 @@ data class AutoFocusTask(
break
}

if (focuser.position == 0) {
// Break out when the focuser hits the zero position. It can't continue from there.
LOG.error("failed to complete. position reached 0")
if (focuser.position <= 0 || focuser.position >= focuser.maxPosition) {
// Break out when the focuser hits the min/max position. It can't continue from there.
LOG.error("failed to complete. position reached ${focuser.position}")
break
}
} while (!cancellationToken.isCancelled && (rightCount + focusPoints.count { it.x > trendLineCurve!!.minimum.x && it.y == 0.0 } < offsetSteps || leftCount + focusPoints.count { it.x < trendLineCurve!!.minimum.x && it.y == 0.0 } < offsetSteps))
Expand All @@ -173,18 +179,22 @@ data class AutoFocusTask(
continue
} else {
LOG.warn("potentially bad auto-focus. Restoring original focus position")
moveFocuser(initialFocusPosition, cancellationToken, false)
break
}
} else {
LOG.info("Auto Focus completed. x={}, y={}", finalFocusPoint.x, finalFocusPoint.y)
}

exited = true
}

if (exited || cancellationToken.isCancelled) {
LOG.warn("did not complete successfully, so restoring the focuser position to $initialFocusPosition")
if (exited) {
sendEvent(AutoFocusState.FAILED)
LOG.warn("Auto Focus did not complete successfully, so restoring the focuser position to $initialFocusPosition")
moveFocuser(initialFocusPosition, CancellationToken.NONE, false)
}

reset()
sendEvent(AutoFocusState.FINISHED)

LOG.info("Auto Focus finished. camera={}, focuser={}", camera, focuser)
}
Expand Down Expand Up @@ -212,25 +222,33 @@ data class AutoFocusTask(
sumVariances += hfdStandardDeviation * hfdStandardDeviation
}

return MeasuredStars(sumHFD / request.capture.exposureAmount, sqrt(sumVariances / request.capture.exposureAmount))
return MeasuredStars(sumHFD / measurements.size, sqrt(sumVariances / measurements.size))
}

override fun accept(event: CameraCaptureEvent) {
if (event.state == CameraCaptureState.EXPOSURE_FINISHED) {
sendEvent(AutoFocusState.COMPUTING, capture = event)
val image = imageBucket.open(event.savePath!!)
val detectedStars = starDetection.detect(image)
LOG.info("detected ${detectedStars.size} stars")
val measure = detectedStars.measureDetectedStars()
LOG.info("HFD measurement. mean={}, stdDev={}", measure.averageHFD, measure.hfdStandardDeviation)
measurements.add(measure)
onNext(event)
} else {
sendEvent(AutoFocusState.EXPOSURING, capture = event)
}
}

private fun takeExposure(cancellationToken: CancellationToken): MeasuredStars {
measurements.clear()
cameraCaptureTask.execute(cancellationToken)
return evaluateAllMeasurements()
return if (!cancellationToken.isCancelled) {
measurements.clear()
sendEvent(AutoFocusState.EXPOSURING)
cameraCaptureTask.execute(cancellationToken)
evaluateAllMeasurements()
} else {
MeasuredStars.ZERO
}
}

private fun obtainFocusPoints(numberOfSteps: Int, offset: Int, reverse: Boolean, cancellationToken: CancellationToken) {
Expand Down Expand Up @@ -258,22 +276,22 @@ data class AutoFocusTask(

// If star measurement is 0, we didn't detect any stars or shapes,
// and want this point to be ignored by the fitting as much as possible.
// Setting a very high Stdev will do the trick.
if (measurement.averageHFD == 0.0) {
LOG.warn("No stars detected in step. Setting a high standard deviation to ignore the point.")
measurement.hfdStandardDeviation = 1000.0
}
LOG.warn("No stars detected in step")
sendEvent(AutoFocusState.FAILED)
} else {
focusPoint = CurvePoint(currentFocusPosition.toDouble(), measurement.averageHFD)
focusPoints.add(focusPoint)
focusPoints.sortBy { it.x }

val weight = max(0.001, measurement.hfdStandardDeviation)
val point = CurvePoint(currentFocusPosition.toDouble(), measurement.averageHFD, weight)
focusPoints.add(point)
focusPoints.sortBy { it.x }
LOG.info("focus point added. remainingSteps={}, point={}", remainingSteps, focusPoint)

remainingSteps--
computeCurveFittings()

LOG.info("focus point added. remainingSteps={}, x={}, y={}, weight={}", remainingSteps, point.x, point.y, point.weight)
sendEvent(AutoFocusState.FOCUS_POINT_ADDED)
}

computeCurveFittings()
remainingSteps--
}
}

Expand All @@ -295,9 +313,9 @@ data class AutoFocusTask(
private fun validateCalculatedFocusPosition(focusPoint: CurvePoint, initialHFD: Double, cancellationToken: CancellationToken): Boolean {
val threshold = request.rSquaredThreshold

fun isTrendLineBad() = trendLineCurve?.let { it.left.rSquared < threshold || it.right.rSquared < threshold } ?: false
fun isParabolicBad() = parabolicCurve?.value?.let { it.rSquared < threshold } ?: false
fun isHyperbolicBad() = hyperbolicCurve?.value?.let { it.rSquared < threshold } ?: false
fun isTrendLineBad() = trendLineCurve?.let { it.left.rSquared < threshold || it.right.rSquared < threshold } ?: true
fun isParabolicBad() = parabolicCurve?.value?.let { it.rSquared < threshold } ?: true
fun isHyperbolicBad() = hyperbolicCurve?.value?.let { it.rSquared < threshold } ?: true

if (threshold > 0.0) {
val isBad = when (request.fittingMode) {
Expand Down Expand Up @@ -338,10 +356,16 @@ data class AutoFocusTask(
private fun moveFocuser(position: Int, cancellationToken: CancellationToken, relative: Boolean): Int {
focuserMoveTask = if (relative) FocuserMoveRelativeTask(focuser, position)
else FocuserMoveAbsoluteTask(focuser, position)
sendEvent(AutoFocusState.MOVING)
focuserMoveTask!!.execute(cancellationToken)
return focuser.position
}

@Suppress("NOTHING_TO_INLINE")
private inline fun sendEvent(state: AutoFocusState, capture: CameraCaptureEvent? = null) {
onNext(AutoFocusEvent(state, focusPoint, capture))
}

override fun reset() {
cameraCaptureTask.reset()
focusPoints.clear()
Expand Down
18 changes: 14 additions & 4 deletions api/src/test/kotlin/APITest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.logging.HttpLoggingInterceptor
import java.nio.file.Path
import java.time.Duration
import java.time.temporal.ChronoUnit

@EnabledIf(NonGitHubOnlyCondition::class)
class APITest : StringSpec() {
Expand Down Expand Up @@ -59,12 +58,15 @@ class APITest : StringSpec() {

"Auto Focus Start" {
connect()
delay(2000)
delay(1000)
cameraConnect()
focuserConnect()
delay(1000)
focuserMoveTo(position = 36000)
delay(2000)
autoFocusStart()
}
"Auto Focus Stop" { autoFocusStop() }
}

private fun connect(host: String = "0.0.0.0", port: Int = 7624) {
Expand Down Expand Up @@ -143,10 +145,18 @@ class APITest : StringSpec() {
get("focusers/$focuser")
}

private fun focuserMoveTo(focuser: String = FOCUSER_NAME, position: Int) {
put("focusers/$focuser/move-to?steps=$position")
}

private fun autoFocusStart(camera: String = CAMERA_NAME, focuser: String = FOCUSER_NAME) {
putJson("auto-focus/$camera/$focuser/start", AUTO_FOCUS_REQUEST)
}

private fun autoFocusStop(camera: String = CAMERA_NAME) {
put("auto-focus/$camera/stop")
}

companion object {

private const val BASE_URL = "http://localhost:7000"
Expand All @@ -159,10 +169,10 @@ class APITest : StringSpec() {

@JvmStatic private val CAMERA_START_CAPTURE_REQUEST = CameraStartCaptureRequest(
exposureTime = EXPOSURE_TIME, width = 1280, height = 1024, frameFormat = "INDI_MONO",
savePath = CAPTURES_PATH, exposureAmount = 2
savePath = CAPTURES_PATH, exposureAmount = 1
)

@JvmStatic private val AUTO_FOCUS_REQUEST = AutoFocusRequest(capture = CAMERA_START_CAPTURE_REQUEST)
@JvmStatic private val AUTO_FOCUS_REQUEST = AutoFocusRequest(capture = CAMERA_START_CAPTURE_REQUEST, stepSize = 11000)

@JvmStatic private val CLIENT = OkHttpClient.Builder()
.addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY))
Expand Down
10 changes: 8 additions & 2 deletions nebulosa-common/src/test/kotlin/CancellationTokenTest.kt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.booleans.shouldBeFalse
import io.kotest.matchers.booleans.shouldBeTrue
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.shouldBe
Expand All @@ -16,6 +17,7 @@ class CancellationTokenTest : StringSpec() {
token.get() shouldBe source
source shouldBe CancellationSource.Cancel(false)
token.isCancelled.shouldBeTrue()
token.isDone.shouldBeTrue()
}
"cancel may interrupt if running" {
var source: CancellationSource? = null
Expand All @@ -25,6 +27,7 @@ class CancellationTokenTest : StringSpec() {
token.get() shouldBe source
source shouldBe CancellationSource.Cancel(true)
token.isCancelled.shouldBeTrue()
token.isDone.shouldBeTrue()
}
"close" {
var source: CancellationSource? = null
Expand All @@ -34,24 +37,27 @@ class CancellationTokenTest : StringSpec() {
token.get() shouldBe source
source shouldBe CancellationSource.Close
token.isCancelled.shouldBeTrue()
token.isDone.shouldBeTrue()
}
"listen" {
"listen after cancel" {
var source: CancellationSource? = null
val token = CancellationToken()
token.cancel()
token.listen { source = it }
token.get() shouldBe CancellationSource.Cancel(true)
source shouldBe CancellationSource.Listen
token.isCancelled.shouldBeTrue()
token.isDone.shouldBeTrue()
}
"none" {
var source: CancellationSource? = null
val token = CancellationToken.NONE
token.isCancelled.shouldBeTrue()
token.listen { source = it }
token.cancel()
token.get() shouldBe CancellationSource.None
source.shouldBeNull()
token.isCancelled.shouldBeFalse()
token.isDone.shouldBeTrue()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,20 @@ package nebulosa.curve.fitting

import org.apache.commons.math3.fitting.WeightedObservedPoint

class CurvePoint(x: Double, y: Double, weight: Double = 1.0) : WeightedObservedPoint(weight, x, y) {
class CurvePoint(x: Double, y: Double) : WeightedObservedPoint(1.0, x, y) {

override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is CurvePoint) return false

if (x != other.x) return false
if (y != other.y) return false

return weight == other.weight
return y == other.y
}

override fun hashCode(): Int {
var result = x.hashCode()
result = 31 * result + y.hashCode()
result = 31 * result + weight.hashCode()
return result
}

Expand Down

0 comments on commit 860b3ac

Please sign in to comment.