diff --git a/alpaca/camera.py b/alpaca/camera.py
index f1e1391..6eb7b80 100644
--- a/alpaca/camera.py
+++ b/alpaca/camera.py
@@ -1233,6 +1233,48 @@ def ImageArray(self) -> List[int]:
"""
return self._get_imagedata("imagearray")
+ @property
+ def ImageArrayRaw(self) -> array.array:
+ """Return an array containing the exposure pixel values.
+
+ Raises:
+ InvalidOperationException: If no image data is available
+ NotConnectedException: If the device is not connected
+ DriverException: An error occurred that is not described by one of the more specific ASCOM exceptions. The device did not *successfully* complete the request.
+
+ Note:
+ * The returned array is in row-major format, and typically must be transposed
+ for use with *numpy* and *astropy* for creating FITS files. See the example
+ below.
+ * Automatically adapts to devices returning either JSON image data or the much
+ faster ImageBytes format. In either case the returned array
+ contains standard Python int or float pixel values. See the
+ |ImageBytes|.
+ See :attr:`ImageArrayInfo` for metadata covering the returned image data.
+
+ .. |ImageBytes| raw:: html
+
+
+ Alpaca API Reference (external)
+
+ .. admonition:: Master Interfaces Reference
+ :class: green
+
+ .. only:: html
+
+ |ImageArray|
+
+ .. |ImageArray| raw:: html
+
+
+ Camera.ImageArray (external)
+
+ .. only:: rinoh
+
+ `Camera.ImageArray `_
+ """
+ return self._get_imagedata_as_array("imagearray")
+
@property
def ImageArrayInfo(self) -> ImageMetadata:
"""Get image metadata sucn as dimensions, data type, rank.
@@ -2363,7 +2405,7 @@ def StopExposure(self) -> None:
# === LOW LEVEL ROUTINES TO GET IMAGE DATA WITH OPTIONAL IMAGEBYTES ===
# https://www.w3resource.com/python/python-bytes.php#byte-string
- def _get_imagedata(self, attribute: str, **data) -> str:
+ def _fetch_imagedata_response(self, attribute: str, **data) -> requests.Response:
"""TBD
Args:
@@ -2388,100 +2430,151 @@ def _get_imagedata(self, attribute: str, **data) -> str:
finally:
Device._ctid_lock.release()
- if response.status_code not in range(200, 204): # HTTP level errors
+ if response.status_code not in range(200, 204): # HTTP level errors
raise AlpacaRequestException(response.status_code,
- f"{response.reason}: {response.text} (URL {response.url})")
+ f"{response.reason}: {response.text} (URL {response.url})")
+
+ return response
+
+ def _get_json_imagedata(self, response):
+ j = response.json()
+ n = j["ErrorNumber"]
+ m = j["ErrorMessage"]
+ raise_alpaca_if(n, m) # Raise Alpaca Exception if non-zero Alpaca error
+ l = j["Value"] # Nested lists
+ if type(l[0][0]) == list: # Test & pick up color plane
+ r = 3
+ d3 = len(l[0][0])
+ else:
+ r = 2
+ d3 = 0
+ self.img_desc = ImageMetadata(
+ 1, # Meta version
+ ImageArrayElementTypes.Int32, # Image element type
+ ImageArrayElementTypes.Int32, # Xmsn element type
+ r, # Rank
+ len(l), # Dimension 1
+ len(l[0]), # Dimension 2
+ d3 # Dimension 3
+ )
+ return l
+
+ def _build_imagedata_array(self, response):
+ m = 'little'
+ b = response.content
+ n = int.from_bytes(b[4:8], m)
+ if n != 0:
+ m = response.text[44:].decode(encoding='UTF-8')
+ raise_alpaca_if(n, m) # Will raise here
+ self.img_desc = ImageMetadata(
+ int.from_bytes(b[0:4], m), # Meta version
+ int.from_bytes(b[20:24], m), # Image element type
+ int.from_bytes(b[24:28], m), # Xmsn element type
+ int.from_bytes(b[28:32], m), # Rank
+ int.from_bytes(b[32:36], m), # Dimension 1
+ int.from_bytes(b[36:40], m), # Dimension 2
+ int.from_bytes(b[40:44], m) # Dimension 3
+ )
+ #
+ # Bless you Kelly Bundy and Mark Ransom
+ # https://stackoverflow.com/questions/71774719/native-array-frombytes-not-numpy-mysterious-behavior/71776522#71776522
+ #
+ if self.img_desc.TransmissionElementType == ImageArrayElementTypes.Int16.value:
+ tcode = 'h'
+ elif self.img_desc.TransmissionElementType == ImageArrayElementTypes.UInt16.value:
+ tcode = 'H'
+ elif self.img_desc.TransmissionElementType == ImageArrayElementTypes.Int32.value:
+ tcode = 'i'
+ elif self.img_desc.TransmissionElementType == ImageArrayElementTypes.Double.value:
+ tcode = 'd'
+ # Extension types for future. 64-bit pixels are unlikely to be seen on the wire
+ elif self.img_desc.TransmissionElementType == ImageArrayElementTypes.Byte.value:
+ tcode = 'B' # Unsigned
+ elif self.img_desc.TransmissionElementType == ImageArrayElementTypes.UInt32.value:
+ tcode = 'I'
+ else:
+ raise InvalidValueException("Unknown or as-yet unsupported ImageBytes Transmission Array Element Type")
+ #
+ # Assemble byte stream back into indexable machine data types
+ #
+ a = array.array(tcode)
+ data_start = int.from_bytes(b[16:20], m)
+ a.frombytes(b[data_start:]) # 'h', 'H', 16-bit ints 2 bytes get turned into Python 32-bit ints
+
+ return a
- ct = response.headers.get('content-type') # case insensitive
+ def _build_imagedata_nested_list_array(self, a: array.array):
+ #
+ # Convert to common Python nested list "array".
+ #
+ l = []
+ rows = self.img_desc.Dimension1
+ cols = self.img_desc.Dimension2
+ if self.img_desc.Rank == 3:
+ for i in range(rows):
+ # rowidx = i * cols * 3
+ r = []
+ for j in range(cols):
+ colidx = j * 3
+ r.append(a[colidx:colidx + 3])
+ l.append(r)
+ else:
+ for i in range(rows):
+ rowidx = i * cols
+ l.append(a[rowidx:rowidx + cols])
+
+ return l # Nested lists
+
+ def _get_imagedata(self, attribute: str, **data):
+ """
+ Fetch image data from the Alpaca server.
+
+ - If the server responds with `application/imagebytes`, returns a
+ nested list (row-major order) reconstructed from the binary stream.
+ - If the server responds with JSON image data, returns the nested list
+ structure provided by the server.
+
+ This method is intended for general compatibility and will always
+ return nested Python lists regardless of the wire format.
+ """
+ response = self._fetch_imagedata_response(attribute, **data)
+ ct = response.headers.get('content-type') # case insensitive
m = 'little'
#
# IMAGEBYTES
#
if ct == 'application/imagebytes':
- b = response.content
- n = int.from_bytes(b[4:8], m)
- if n != 0:
- m = response.text[44:].decode(encoding='UTF-8')
- raise_alpaca_if(n, m) # Will raise here
- self.img_desc = ImageMetadata(
- int.from_bytes(b[0:4], m), # Meta version
- int.from_bytes(b[20:24], m), # Image element type
- int.from_bytes(b[24:28], m), # Xmsn element type
- int.from_bytes(b[28:32], m), # Rank
- int.from_bytes(b[32:36], m), # Dimension 1
- int.from_bytes(b[36:40], m), # Dimension 2
- int.from_bytes(b[40:44], m) # Dimension 3
- )
- #
- # Bless you Kelly Bundy and Mark Ransom
- # https://stackoverflow.com/questions/71774719/native-array-frombytes-not-numpy-mysterious-behavior/71776522#71776522
- #
- if self.img_desc.TransmissionElementType == ImageArrayElementTypes.Int16.value:
- tcode = 'h'
- elif self.img_desc.TransmissionElementType == ImageArrayElementTypes.UInt16.value:
- tcode = 'H'
- elif self.img_desc.TransmissionElementType == ImageArrayElementTypes.Int32.value:
- tcode = 'l'
- elif self.img_desc.TransmissionElementType == ImageArrayElementTypes.Double.value:
- tcode = 'd'
- # Extension types for future. 64-bit pixels are unlikely to be seen on the wire
- elif self.img_desc.TransmissionElementType == ImageArrayElementTypes.Byte.value:
- tcode = 'B' # Unsigned
- elif self.img_desc.TransmissionElementType == ImageArrayElementTypes.UInt32.value:
- tcode = 'L'
- else:
- raise InvalidValueException("Unknown or as-yet unsupported ImageBytes Transmission Array Element Type")
- #
- # Assemble byte stream back into indexable machine data types
- #
- a = array.array(tcode)
- data_start = int.from_bytes(b[16:20],m)
- a.frombytes(b[data_start:]) # 'h', 'H', 16-bit ints 2 bytes get turned into Python 32-bit ints
- #
- # Convert to common Python nested list "array".
- #
- l = []
- rows = self.img_desc.Dimension1
- cols = self.img_desc.Dimension2
- if self.img_desc.Rank == 3:
- for i in range(rows):
- rowidx = i * cols * 3
- r = []
- for j in range(cols):
- colidx = j * 3
- r.append(a[colidx:colidx+3])
- l.append(r)
- else:
- for i in range(rows):
- rowidx = i * cols
- l.append(a[rowidx:rowidx+cols])
-
- return l # Nested lists
+ a = self._build_imagedata_array(response)
+ return self._build_imagedata_nested_list_array(a)
#
# JSON IMAGE DATA -> List of Lists (row major)
#
else:
- j = response.json()
- n = j["ErrorNumber"]
- m = j["ErrorMessage"]
- raise_alpaca_if(n, m) # Raise Alpaca Exception if non-zero Alpaca error
- l = j["Value"] # Nested lists
- if type(l[0][0]) == list: # Test & pick up color plane
- r = 3
- d3 = len(l[0][0])
- else:
- r = 2
- d3 = 0
- self.img_desc = ImageMetadata(
- 1, # Meta version
- ImageArrayElementTypes.Int32, # Image element type
- ImageArrayElementTypes.Int32, # Xmsn element type
- r, # Rank
- len(l), # Dimension 1
- len(l[0]), # Dimension 2
- d3 # Dimension 3
+ return self._get_json_imagedata(response)
+
+ def _get_imagedata_as_array(self, attribute: str, **data):
+ """
+ Fetch image data from the Alpaca server and return it as a flat
+ `array.array` of raw pixel values.
+
+ - Only supported if the server responds with `application/imagebytes`.
+ - If the server responds with JSON image data instead, this method
+ raises `InvalidValueException`.
+
+ This method is intended for high-performance consumers who want direct
+ access to the raw pixel buffer. Use `_get_imagedata()` if you also
+ need to support JSON image responses.
+ """
+ response = self._fetch_imagedata_response(attribute, **data)
+ ct = response.headers.get('content-type') # case insensitive
+ if ct == 'application/imagebytes':
+ return self._build_imagedata_array(response)
+ else:
+ raise InvalidValueException(
+ f"Expected 'application/imagebytes' response, got '{ct}'. "
+ "Use _get_imagedata() instead to handle JSON image data."
)
- return l
+
def raise_alpaca_if(n, m):
"""If non-zero Alpaca error, raise the appropriate Alpaca exception
diff --git a/poetry.lock b/poetry.lock
index 4ba8ef8..f7d0729 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,4 +1,4 @@
-# This file is automatically @generated by Poetry 1.8.1 and should not be changed by hand.
+# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
[[package]]
name = "certifi"
@@ -6,6 +6,7 @@ version = "2025.8.3"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.7"
+groups = ["main"]
files = [
{file = "certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"},
{file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"},
@@ -17,6 +18,7 @@ version = "3.4.3"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
optional = false
python-versions = ">=3.7"
+groups = ["main"]
files = [
{file = "charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72"},
{file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe"},
@@ -105,6 +107,8 @@ version = "0.4.6"
description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+groups = ["test"]
+markers = "sys_platform == \"win32\""
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
@@ -116,6 +120,7 @@ version = "0.12.0"
description = "Tools to expand Python's enum module."
optional = false
python-versions = ">=3.6"
+groups = ["main"]
files = [
{file = "enum_tools-0.12.0-py3-none-any.whl", hash = "sha256:d69b019f193c7b850b17d9ce18440db7ed62381571409af80ccc08c5218b340a"},
{file = "enum_tools-0.12.0.tar.gz", hash = "sha256:13ceb9376a4c5f574a1e7c5f9c8eb7f3d3fbfbb361cc18a738df1a58dfefd460"},
@@ -135,6 +140,8 @@ version = "1.3.0"
description = "Backport of PEP 654 (exception groups)"
optional = false
python-versions = ">=3.7"
+groups = ["test"]
+markers = "python_version < \"3.11\""
files = [
{file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"},
{file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"},
@@ -152,6 +159,7 @@ version = "3.10"
description = "Internationalized Domain Names in Applications (IDNA)"
optional = false
python-versions = ">=3.6"
+groups = ["main"]
files = [
{file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
{file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
@@ -166,6 +174,7 @@ version = "0.2.0"
description = "Cross-platform network interface and IP address enumeration library"
optional = false
python-versions = "*"
+groups = ["main"]
files = [
{file = "ifaddr-0.2.0-py3-none-any.whl", hash = "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748"},
{file = "ifaddr-0.2.0.tar.gz", hash = "sha256:cc0cbfcaabf765d44595825fb96a99bb12c79716b73b44330ea38ee2b0c4aed4"},
@@ -177,6 +186,7 @@ version = "2.1.0"
description = "brain-dead simple config-ini parsing"
optional = false
python-versions = ">=3.8"
+groups = ["test"]
files = [
{file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"},
{file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"},
@@ -188,6 +198,7 @@ version = "25.0"
description = "Core utilities for Python packages"
optional = false
python-versions = ">=3.8"
+groups = ["test"]
files = [
{file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"},
{file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
@@ -199,6 +210,7 @@ version = "1.6.0"
description = "plugin and hook calling mechanisms for python"
optional = false
python-versions = ">=3.9"
+groups = ["test"]
files = [
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
@@ -214,6 +226,7 @@ version = "2.19.2"
description = "Pygments is a syntax highlighting package written in Python."
optional = false
python-versions = ">=3.8"
+groups = ["main"]
files = [
{file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
{file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
@@ -228,6 +241,7 @@ version = "7.4.4"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.7"
+groups = ["test"]
files = [
{file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"},
{file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"},
@@ -250,6 +264,7 @@ version = "2.9.0.post0"
description = "Extensions to the standard Python datetime module"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+groups = ["main"]
files = [
{file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"},
{file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"},
@@ -264,6 +279,7 @@ version = "2022.7.1"
description = "World timezone definitions, modern and historical"
optional = false
python-versions = "*"
+groups = ["test"]
files = [
{file = "pytz-2022.7.1-py2.py3-none-any.whl", hash = "sha256:78f4f37d8198e0627c5f1143240bb0206b8691d8d7ac6d78fee88b78733f8c4a"},
{file = "pytz-2022.7.1.tar.gz", hash = "sha256:01a0681c4b9684a28304615eba55d1ab31ae00bf68ec157ec3708a8182dbbcd0"},
@@ -271,13 +287,14 @@ files = [
[[package]]
name = "requests"
-version = "2.32.4"
+version = "2.32.5"
description = "Python HTTP for Humans."
optional = false
-python-versions = ">=3.8"
+python-versions = ">=3.9"
+groups = ["main"]
files = [
- {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"},
- {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"},
+ {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"},
+ {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"},
]
[package.dependencies]
@@ -296,6 +313,7 @@ version = "1.17.0"
description = "Python 2 and 3 compatibility utilities"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+groups = ["main"]
files = [
{file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"},
{file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"},
@@ -307,6 +325,8 @@ version = "2.2.1"
description = "A lil' TOML parser"
optional = false
python-versions = ">=3.8"
+groups = ["test"]
+markers = "python_version < \"3.11\""
files = [
{file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"},
{file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"},
@@ -348,6 +368,7 @@ version = "4.14.1"
description = "Backported and Experimental Type Hints for Python 3.9+"
optional = false
python-versions = ">=3.9"
+groups = ["main", "test"]
files = [
{file = "typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76"},
{file = "typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36"},
@@ -359,18 +380,19 @@ version = "2.5.0"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false
python-versions = ">=3.9"
+groups = ["main"]
files = [
{file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"},
{file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"},
]
[package.extras]
-brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"]
+brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""]
h2 = ["h2 (>=4,<5)"]
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
zstd = ["zstandard (>=0.18.0)"]
[metadata]
-lock-version = "2.0"
+lock-version = "2.1"
python-versions = "^3.9"
-content-hash = "97f920af6813818d9d87dae96ea62b6529746da31b820bc9b57fba7ae6747362"
+content-hash = "23eaa06991cba1a57064b935e9d4d4e8fc79089c40c13a2c69226fca25c9aef5"
diff --git a/pyproject.toml b/pyproject.toml
index 8c26c90..b34889c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -25,7 +25,7 @@ packages = [
[tool.poetry.dependencies]
python = "^3.9"
-requests = "^2.32.1"
+requests = "^2.32.4"
typing-extensions = "^4.12.0"
python-dateutil = "^2.8.2"
enum-tools = "^0.12.0"