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"