CCD astronomical imaging: image acquisition​
​by Tom Sherlock​
Second part (color image processing): https://community.wolfram.com/groups/-/m/t/2565596
I captured these images of M57 and M27 from my backyard in Urbana. Both were captured using a monochrome CCD camera through a series of red, green and blue filters.
The imaging frames plus the calibration frames (dark and bias) were all captured using Mathematica to control the CCD camera and the filter wheel using the ASCOM API and NETLink.
All the alignment, stacking and calibration plus the initial image processing were also done in the same notebook using WL plus some Python routines called from WL code. The only thing not done in Mathematica was the control of the guiding camera which kept the telescope locked onto these objects and some minor touchups in Gimp.
Both images consist of 10, 30-second subframes captured through each of the 3 filters, plus 10 bias frames and 10 dark frames. The camera was cooled to 0 degrees C and this was also set using WL.

Implementation

ASCOM Interface

In[]:=
Needs["NETLink`"];
In[]:=
InitDeviceInterface32[]:=​​Module[{},​​InstallNET["Force32Bit"->True];​​LoadNETAssembly/@Map["ASCOM."<>#&,{"Astrometry","Attributes","DriverAccess","Exceptions","Helper","Helper2","Interfaces","Utilities"}];​​LoadNETType["ASCOM.DriverAccess.Camera"];​​LoadNETType["ASCOM.DriverAccess.FilterWheel"];​​];
In[]:=
SelectDevice[deviceName_String]:=Module[{theDevice},theDevice=NETNew["ASCOM.DriverAccess."<>deviceName,ToExpression[deviceName<>"`Choose[\"\"]"]];​​theDevice@Connected=True;​​theDevice]
In[]:=
DisconnectDevice[theDevice_]:=Module[{},theDevice@Connected=False;];

Filter Wheel Control

In[]:=
(*FilterWheelFunctions*)SetFilterWheelPosition[theFilterWheel_,filterPos_]:=Module[{},theFilterWheel@Position=filterPos-1;​​filterPos];​​​​SetFilterWheelPosition[theFilterWheel_,filterName_String]:=Module[{l},l=FirstPosition[GetFilterWheelNames[theFilterWheel],filterName,{}];​​If[Length[l]==1,SetFilterWheelPosition[theFilterWheel,First[l]];filterName,$Failed]];​​​​GetFilterWheelPosition[theFilterWheel_]:=theFilterWheel@Position+1;​​​​GetFilterWheelPosition[theFilterWheel_,filterName_String]:=Module[{l},l=FirstPosition[GetFilterWheelNames[theFilterWheel],filterName,{}];​​If[Length[l]==1,First[l],$Failed]];​​​​GetFilterWheelNames[theFilterWheel_]:=Module[{},theFilterWheel@Names];

Camera Control

In[]:=
GetCameraSensorTemperature[theCamera_]:=Module[{},theCamera@CCDTemperature];
In[]:=
SetCameraSensorTemperature[theCamera_,tempDegreesCelcius_]:=Module[{},theCamera@SetCCDTemperature=tempDegreesCelcius;​​tempDegreesCelcius];
In[]:=
askDarkFrame=True;​​SetAskDarkFrame[ask_]:=Block[{},askDarkFrame=ask;ask];
In[]:=
SetCameraCoolerOn[theCamera_,theValue_]:=Block[{},theCamera@CoolerOn=theValue;theCamera@CoolerOn];
In[]:=
GetCameraExposureMin[theCamera_]:=theCamera@ExposureMin;
In[]:=
GetCameraMaxBinX[theCamera_]:=theCamera@MaxBinX;​​GetCameraMaxBinY[theCamera_]:=theCamera@MaxBinY;​​GetCameraXSize[theCamera_]:=theCamera@CameraXSize;​​GetCameraYSize[theCamera_]:=theCamera@CameraYSize;​​SetCameraNumX[theCamera_,theValue_]:=Block[{},theCamera@NumX=theValue;theCamera@NumX];​​SetCameraNumY[theCamera_,theValue_]:=Block[{},theCamera@NumY=theValue;theCamera@NumY];
SetCameraBinning[theCamera_,bin_Integer]:=Module[{xres,yres},If[bin>GetCameraMaxBinX[theCamera],Return[$Failed]];​​If[bin>GetCameraMaxBinY[theCamera],Return[$Failed]];​​If[bin<1,Return[$Failed]];​​xres=GetCameraXSize[theCamera]/bin;​​yres=GetCameraYSize[theCamera]/bin;​​theCamera@BinX=bin;​​theCamera@BinY=bin;​​SetCameraNumX[theCamera,xres];​​SetCameraNumY[theCamera,yres];​​{bin,bin}];
GetCameraBinning[theCamera_]:=Module[{},List[theCamera@BinX,theCamera@BinY]];

Capture Utilities

In[]:=
AcquireImageToFITSFile[theCamera_,duration_,shutterOpen_,fileName_]:=Module[{im,sample},theCamera@StartExposure[duration,shutterOpen];​​While[theCamera@ImageReady!=True,Pause[0.5]];​​im=Image[Transpose[theCamera@ImageArray],"Bit16"];​​Export[fileName,ImageData[im,Interleaving->False,DataReversed->True]]];​​​​buildImageFITSFileName[prefix_String,outputDir_String,index_]:=FileNameJoin[{outputDir,prefix<>"_"<>StringReplace[DateString["DateShort"]," "->"_"]<>"_"<>ToString[index]<>".fit"}];​​​​AcquireFITSImageSequence[theCamera_,duration_,shutterOpen_,count_,prefix_String,outputDir_String]:=​​Module[{i,fileName,imageType},If[shutterOpen==False&&theCamera@HasShutter==False&&askDarkFrame==True,CreateDialog[{TextCell["Acquiring dark frames. Cover the telescope objective"],DefaultButton[]}]];​​For[i=1,i<=count,i++,fileName=buildImageFITSFileName[prefix,outputDir,i];​​AcquireImageToFITSFile[theCamera,duration,shutterOpen,fileName]];​​If[shutterOpen==False&&theCamera@HasShutter==False&&askDarkFrame==True,CreateDialog[{TextCell["Acquisition complete. Uncover the telescope objective"],DefaultButton[]}]];];
In[]:=
AcquireLRGBSequence[theCamera_,theFilterWheel_,frameExposure_,frameCount_,outputDir_]:=​​Module[{},​​(*Uselowresolutionforcolorframes*)​​ SetCameraBinning[theCamera,1];​​ Print["Acquiring Red Frames..."];​​ SetFilterWheelPosition[theFilterWheel,"Red"];​​ AcquireFITSImageSequence[theCamera,frameExposure,True,frameCount,"red",outputDir];​​ Print["Acquiring Green Frames..."];​​SetFilterWheelPosition[theFilterWheel,"Green"];​​ AcquireFITSImageSequence[theCamera,frameExposure,True,frameCount,"green",outputDir];​​ Print["Acquiring Blue Frames..."];​​ SetFilterWheelPosition[theFilterWheel,"Blue"];​​ AcquireFITSImageSequence[theCamera,frameExposure,True,frameCount,"blue",outputDir];​​ Print["Acquiring Luminance Frames..."];​​ SetFilterWheelPosition[theFilterWheel,"Luminance"];​​ AcquireFITSImageSequence[theCamera,frameExposure,True,frameCount,"luminance",outputDir];​​];
Initialize the ASCOM interface
In[]:=
InitDeviceInterface32[];

Collection

Setup

Location for data
In[]:=
outputDir="C:\\Users\\tom\\Pictures\\TestArea2";
Number of frames to collect of each type
In[]:=
frameCount=10;
Basic exposure for each light and dark frame in seconds
In[]:=
frameExposure=30.0;
Offset in degrees Celsius below ambient temperature for the CCD sensor
In[]:=
sensorTemperatureOffset=20.0;

Select devices

Evaluating this cell will bring up dialogs to choose the camera and filter wheel.
In[]:=
theCamera=SelectDevice["Camera"];​​theFilterWheel=SelectDevice["FilterWheel"];

Show the filter names

In[]:=
filterNames=GetFilterWheelNames[theFilterWheel]
Out[]=
{Luminance,Red,Green,Blue,Dark Frame}

Set camera temperature

Acquire images at 20 degrees C below ambient to minimize thermal noise from the CCD chip.
In[]:=
ambientTemp=GetCameraSensorTemperature[theCamera]
In[]:=
SetCameraCoolerOn[True];
In[]:=
SetCameraSensorTemperature[theCamera,ambientTemp-sensorTemperatureOffset];

Collect Calibration Frames (Bias and Dark Frames)

Bias Frames

Bias frames are collected at a very short exposure time with the shutter closed to estimate the noise associated with reading a light frame. This camera doesn’t have a dedicated shutter so I use an opaque filter as a shutter. For this reason we don’t need to ask the user to cover the telescope.
SetAskDarkFrame[False];
biasDir=FileNameJoin[{outputDir,"Bias"}];​​SetFilterWheelPosition[theFilterWheel,"Dark Frame"];​​SetCameraBinning[theCamera,1];​​AcquireFITSImageSequence[theCamera,GetCameraExposureMin[theCamera],False,frameCount,"bias",biasDir];

Dark Frames

Dark frames are collected with the shutter closed at the same exposure time and temperature as the light frames to estimate the thermal noise associated with the camera.
In[]:=
darkDir=FileNameJoin[{outputDir,"Darks"}];​​SetFilterWheelPosition[theFilterWheel,"Dark Frame"];​​SetCameraBinning[theCamera,1];​​AcquireFITSImageSequence[theCamera,frameExposure,False,frameCount,"dark",darkDir];

Flat Frames

In this demo I don’t collect ‘flat’ frames, but for precise work or larger sensors you need to do so. Flat frames are exposed with the telescope pointed at an evenly illuminated surface, like a dedicated flat frame target or simply the evening sky. You need to collect flat frames for each filter to compensate for things like dust on the filter.

Acquire LRGB Light Image Frames

These frames contain the actual image data. We collect them for the R, G, B filters as well as a clear filter to get luminance data.
An alternative procedure is to collect the R,G and B frames at half the resolution by setting the binning to 2. This will collect 4 times the light per ‘pixel’ at the expense of half the lower spatial resolution for the color data, which isn’t critical. The luminance frames will always be collected at high resolution. If low resolution frames are collected, you will also need to collect low resolution dark and bias frames too.
In[]:=
lightDir=FileNameJoin[{outputDir,"Lights"}];
In[]:=
AcquireLRGBSequence[theCamera,theFilterWheel,frameExposure,frameCount,lightDir]
Acquiring Red Frames...
Acquiring Green Frames...
Acquiring Blue Frames...
Acquiring Luminance Frames...

Disconnect devices

In[]:=
DisconnectDevice[theFilterWheel];​​DisconnectDevice[theCamera];