Skip to content

Commit

Permalink
feat: save view as image of disk [#108]
Browse files Browse the repository at this point in the history
* ✨ Implement view export as image file (png, jpeg or webp)

* 📝 Update changelog

* rename method to save_view_as_image
  • Loading branch information
Xen0Xys authored Aug 2, 2024
1 parent 0f478c5 commit 73ab846
Show file tree
Hide file tree
Showing 7 changed files with 131 additions and 60 deletions.
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,7 @@ conda-recipe/ipyaladin

# Documentation
docs/_build/
docs/_collections/
docs/_collections/

# Examples
examples/*.png
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- New way to make a selection on the view with `selection` method (#100)
- Add selected sources export as `astropy.Table` list with property `selected_objects` (#100)
- Add function `get_view_as_fits` to export the view as a `astropy.io.fits.HDUList` (#86)
- Add function `save_view_as_image` to save the view as an image file (#108)

### Deprecated

Expand Down
41 changes: 4 additions & 37 deletions examples/03_Functions.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -140,12 +140,10 @@
]
},
{
"cell_type": "code",
"execution_count": null,
"cell_type": "markdown",
"metadata": {},
"outputs": [],
"source": [
"help(aladin_bis.get_JPEG_thumbnail)"
"Save the view as an image"
]
},
{
Expand All @@ -154,42 +152,11 @@
"metadata": {},
"outputs": [],
"source": [
"aladin_bis.get_JPEG_thumbnail()"
]
},
{
"attachments": {},
"cell_type": "markdown",
"metadata": {},
"source": [
"Check that your browser didn't block the popup window if you don't see the thumbnail. This will not work in VSCode or other notebooks editors that are not working in a browser."
"aladin_bis.save_view_as_image(\"4Sgr.png\")"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "base",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.11.8"
},
"vscode": {
"interpreter": {
"hash": "85bb43f988bdbdc027a50b6d744a62eda8a76617af1f4f9b115d38242716dbac"
}
}
},
"metadata": {},
"nbformat": 4,
"nbformat_minor": 4
}
26 changes: 23 additions & 3 deletions examples/11_Extracting_information_from_the_view.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
"id": "998def1f-3963-405b-8be2-6d4ef4012634",
"metadata": {},
"source": [
"If you edit the view either by modifiing the widget through its interface, or programmatically: "
"If you edit the view either by modifiing the widget through its interface, or programmatically:"
]
},
{
Expand Down Expand Up @@ -106,7 +106,7 @@
"id": "f5add3a2-be30-488e-86df-426338b98f5d",
"metadata": {},
"source": [
"If you try to recover the value in the **same cell**, you'll get a `WidgetCommunicationError` error. This is because the calculation of the WCS is done by Aladin Lite *between* cell executions. \n",
"If you try to recover the value in the **same cell**, you'll get a `WidgetCommunicationError` error. This is because the calculation of the WCS is done by Aladin Lite *between* cell executions.\n",
"\n",
"## Getting the field of view\n",
"\n",
Expand Down Expand Up @@ -204,7 +204,7 @@
"metadata": {},
"source": [
"## Getting the view as a fits file\n",
"The following method allow you to retrieve the current view as a fits file. If a `path` is given as a second argument, the fits file will be saved."
"The following method allow you to retrieve the current view as a fits file."
]
},
{
Expand Down Expand Up @@ -247,6 +247,26 @@
"plt.subplot(projection=wcs)\n",
"plt.imshow(fits[0].data, cmap=\"binary_r\", norm=\"asinh\", vmin=0.001)"
]
},
{
"cell_type": "markdown",
"id": "c64190a2757b707",
"metadata": {},
"source": [
"## Saving the view as an image file\n",
"\n",
"In `save_view_as_image`, the first argument is the path to the file, the second is the format (\"png\", \"jpeg\", \"webp\"), and the third is a boolean to indicate if you want to include the Aladin Lite logo in the image."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "85f40dc5e6af3ae1",
"metadata": {},
"outputs": [],
"source": [
"aladin.save_view_as_image(path=\"./crab.png\", image_format=\"png\", with_logo=True)"
]
}
],
"metadata": {
Expand Down
1 change: 1 addition & 0 deletions js/models/event_handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ export default class EventHandler {
this.eventHandlers = {
change_fov: this.messageHandler.handleChangeFoV,
goto_ra_dec: this.messageHandler.handleGotoRaDec,
save_view_as_image: this.messageHandler.handleSaveViewAsImage,
add_fits: this.messageHandler.handleAddFits,
add_catalog_from_URL: this.messageHandler.handleAddCatalogFromURL,
add_MOC_from_URL: this.messageHandler.handleAddMOCFromURL,
Expand Down
19 changes: 19 additions & 0 deletions js/models/message_handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,25 @@ export default class MessageHandler {
this.aladin.gotoRaDec(msg["ra"], msg["dec"]);
}

async handleSaveViewAsImage(msg) {
const path = msg["path"];
const format = msg["format"];
const withLogo = msg["with_logo"];
const buffer = await this.aladin.getViewData(
"arraybuffer",
`image/${format}`,
withLogo,
);
this.model.send(
{
event_type: "save_view_as_image",
path: path,
},
null,
[buffer],
);
}

handleAddFits(msg, buffers) {
const options = convertOptionNamesToCamelCase(msg["options"] || {});
if (!options.name)
Expand Down
98 changes: 79 additions & 19 deletions src/ipyaladin/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ class Aladin(anywidget.AnyWidget):
)
# reticle
show_reticle = Bool(
True, help="Wether to show the reticle in the middle of the view."
True, help="Whether to show the reticle in the middle of the view."
).tag(sync=True, init_option=True, only_init=True)
reticle_color = Unicode("rgb(178, 50, 178)", help="The color of the reticle.").tag(
sync=True, init_option=True, only_init=True
Expand All @@ -175,7 +175,7 @@ class Aladin(anywidget.AnyWidget):
False, help="Whether the coordinates grid should be shown at startup."
).tag(sync=True, init_option=True, only_init=True)
show_coo_grid_control = Bool(
True, help="Whether to show the coordinate grid control toolbar. "
True, help="Whether to show the coordinate grid control toolbar."
).tag(sync=True, init_option=True, only_init=True)
grid_color = Unicode(
"rgb(178, 50, 178)",
Expand Down Expand Up @@ -208,7 +208,7 @@ class Aladin(anywidget.AnyWidget):
overlay_survey_opacity = Float(0.0).tag(sync=True, init_option=True)
_base_layer_last_view = Unicode(
survey.default_value,
help="The last view of the base layer. It is used "
help="A private trait for the base layer of the last view. It is useful "
"to convert the view to an astropy.HDUList",
).tag(sync=True)

Expand All @@ -225,23 +225,24 @@ def __init__(self, *args: any, **kwargs: any) -> None:
self.fov = kwargs.get("fov", 60.0)
self.on_msg(self._handle_custom_message)

def _handle_custom_message(self, _: any, message: dict, __: any) -> None:
def _handle_custom_message(self, _: any, message: dict, buffers: any) -> None:
event_type = message["event_type"]
message_content = message["content"]
if (
event_type == "object_clicked"
and "object_clicked" in self.listener_callback
):
self.listener_callback["object_clicked"](message_content)
self.listener_callback["object_clicked"](message["content"])
elif (
event_type == "object_hovered"
and "object_hovered" in self.listener_callback
):
self.listener_callback["object_hovered"](message_content)
self.listener_callback["object_hovered"](message["content"])
elif event_type == "click" and "click" in self.listener_callback:
self.listener_callback["click"](message_content)
self.listener_callback["click"](message["content"])
elif event_type == "select" and "select" in self.listener_callback:
self.listener_callback["select"](message_content)
self.listener_callback["select"](message["content"])
elif event_type == "save_view_as_image":
self._save_file(message["path"], buffers[0])

@property
def selected_objects(self) -> List[Table]:
Expand Down Expand Up @@ -291,7 +292,8 @@ def wcs(self) -> WCS:
"""
if self._wcs == {}:
raise WidgetCommunicationError(
"The world coordinate system is not available. "
"The world coordinate system is not available. This often happens when "
"the WCS is modified and read in the same cell. "
"Please recover it from another cell."
)
if "RADECSYS" in self._wcs: # RADECSYS keyword is deprecated for astropy.WCS
Expand All @@ -310,7 +312,8 @@ def fov_xy(self) -> Tuple[Angle, Angle]:
"""
if self._fov_xy == {}:
raise WidgetCommunicationError(
"The field of view along the two axes is not available. "
"The field of view along the two axes is not available. This often "
"happens when the FOV is modified and read in the same cell. "
"Please recover it from another cell."
)
return (
Expand All @@ -330,6 +333,10 @@ def fov(self) -> Angle:
astropy.coordinates.Angle
An astropy.coordinates.Angle object representing the field of view.
See Also
--------
fov_xy
"""
return Angle(self._fov, unit="deg")

Expand Down Expand Up @@ -382,6 +389,50 @@ def target(self, target: Union[str, SkyCoord]) -> None:
}
)

def _save_file(self, path: str, buffer: bytes) -> None:
"""Save a file from a buffer.
Parameters
----------
path : str
The path where the file will be saved.
buffer : bytes
The buffer containing the file.
"""
with Path(path).open("wb") as file:
file.write(buffer)

def save_view_as_image(
self, path: Union[str, Path], image_format: str = "png", with_logo: bool = True
) -> None:
"""Save the current view of the widget as an image file.
Parameters
----------
path : Union[str, Path]
The path where the image will be saved.
image_format : str
The format of the image. Can be 'png', 'jpeg' or 'webp'.
with_logo : bool
Whether to include the Aladin Lite logo in the image.
See Also
--------
get_view_as_fits
"""
if image_format not in {"png", "jpeg", "webp"}:
raise ValueError("image_format must be 'png', 'jpeg' or 'webp")
self.send(
{
"event_name": "save_view_as_image",
"path": str(path),
"format": image_format,
"with_logo": with_logo,
}
)

def get_view_as_fits(self) -> HDUList:
"""Get the base layer of the widget as an astropy HDUList object.
Expand All @@ -394,6 +445,10 @@ def get_view_as_fits(self) -> HDUList:
astropy.io.fits.HDUList
The FITS object containing the image.
See Also
--------
save_view_as_image
"""
try:
from astroquery.hips2fits import hips2fits
Expand All @@ -415,6 +470,19 @@ def get_view_as_fits(self) -> HDUList:
) from e
return fits

def get_JPEG_thumbnail(self) -> None:
"""Create a new tab with the current Aladin view.
This method will only work if you are running a notebook in a browser (for
example, it won't do anything in VSCode).
See Also
--------
save_view_as_image: will save the image on disk instead
"""
self.send({"event_name": "get_JPG_thumbnail"})

def add_catalog_from_URL(
self, votable_URL: str, votable_options: Optional[dict] = None
) -> None:
Expand Down Expand Up @@ -726,14 +794,6 @@ def add_graphic_overlay_from_stcs(
}
)

def get_JPEG_thumbnail(self) -> None:
"""Create a popup window with the current Aladin view.
This method will only work if you are running a notebook in a browser (for
example, it won't do anything in VSCode).
"""
self.send({"event_name": "get_JPG_thumbnail"})

def set_color_map(self, color_map_name: str) -> None:
"""Change the color map of the Aladin Lite widget.
Expand Down

0 comments on commit 73ab846

Please sign in to comment.