diff --git a/apps/routes/cutouts/__init__.py b/apps/routes/cutouts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/routes/cutouts/api.py b/apps/routes/cutouts/api.py new file mode 100644 index 0000000..2283a63 --- /dev/null +++ b/apps/routes/cutouts/api.py @@ -0,0 +1,104 @@ +# Copyright 2024 AstroLab Software +# Author: Julien Peloton +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from flask import Blueprint, Response, jsonify, request +from apps.utils import check_args + +from apps.routes.cutouts.utils import format_and_send_cutout + +bp = Blueprint("cutouts", __name__) + + +# Enable CORS for this blueprint +@bp.after_request +def after_request(response): + response.headers.add("Access-Control-Allow-Origin", "*") + response.headers.add("Access-Control-Allow-Headers", "Content-Type,Authorization") + response.headers.add("Access-Control-Allow-Methods", "GET,PUT,POST,DELETE,OPTIONS") + return response + + +ARGS = [ + { + "name": "objectId", + "required": True, + "description": "ZTF Object ID", + }, + { + "name": "kind", + "required": True, + "description": "Science, Template, or Difference. For output-format=array, you can also specify `kind: All` to get the 3 cutouts.", + }, + { + "name": "output-format", + "required": False, + "description": "PNG[default], FITS, array", + }, + { + "name": "candid", + "required": False, + "description": "Candidate ID of the alert belonging to the object with `objectId`. If not filled, the cutouts of the latest alert is returned", + }, + { + "name": "stretch", + "required": False, + "description": "Stretch function to be applied. Available: sigmoid[default], linear, sqrt, power, log.", + }, + { + "name": "colormap", + "required": False, + "description": "Valid matplotlib colormap name (see matplotlib.cm). Default is grayscale.", + }, + { + "name": "pmin", + "required": False, + "description": "The percentile value used to determine the pixel value of minimum cut level. Default is 0.5. No effect for sigmoid.", + }, + { + "name": "pmax", + "required": False, + "description": "The percentile value used to determine the pixel value of maximum cut level. Default is 99.5. No effect for sigmoid.", + }, + { + "name": "convolution_kernel", + "required": False, + "description": "Convolve the image with a kernel (gauss or box). Default is None (not specified).", + }, +] + + +@bp.route("/api/v1/cutouts", methods=["GET"]) +def return_cutouts_arguments(): + """Obtain information about cutouts""" + if len(request.args) > 0: + # POST from query URL + return return_cutouts(payload=request.args) + else: + return jsonify({"args": ARGS}) + + +@bp.route("/api/v1/cutouts", methods=["POST"]) +def return_cutouts(payload=None): + """Retrieve object data""" + # get payload from the JSON + if payload is None: + payload = request.json + + rep = check_args(ARGS, payload) + if rep["status"] != "ok": + return Response(str(rep), 400) + + assert payload["kind"] in ["Science", "Template", "Difference", "All"] + + return format_and_send_cutout(payload) diff --git a/apps/routes/cutouts/utils.py b/apps/routes/cutouts/utils.py new file mode 100644 index 0000000..a392110 --- /dev/null +++ b/apps/routes/cutouts/utils.py @@ -0,0 +1,180 @@ +# Copyright 2024 AstroLab Software +# Author: Julien Peloton +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from flask import send_file, jsonify + +import io +import json +import requests + +import numpy as np +from matplotlib import cm +from PIL import Image + +from apps.utils.client import connect_to_hbase_table +from apps.utils.plotting import sigmoid_normalizer, legacy_normalizer, convolve +from apps.utils.decoding import format_hbase_output +from apps.utils.utils import extract_configuration + + +def format_and_send_cutout(payload: dict): + """Extract data returned by HBase and jsonify it + + Data is from /api/v1/cutouts + + Parameters + ---------- + payload: dict + See https://fink-portal.org/api/v1/cutouts + + Return + ---------- + out: pandas dataframe + """ + output_format = payload.get("output-format", "PNG") + + # default stretch is sigmoid + if "stretch" in payload: + stretch = payload["stretch"] + else: + stretch = "sigmoid" + + if payload["kind"] == "All" and payload["output-format"] != "array": + # TODO: error 400 + pass + + # default name based on parameters + filename = "{}_{}".format( + payload["objectId"], + payload["kind"], + ) + + if output_format == "PNG": + filename = filename + ".png" + elif output_format == "JPEG": + filename = filename + ".jpg" + elif output_format == "FITS": + filename = filename + ".fits" + + # Query the Database (object query) + client = connect_to_hbase_table("ztf.cutouts") + results = client.scan( + "", + "key:key:{}".format(payload["objectId"]), + "d:hdfs_path,i:jd,i:candid,i:objectId", + 0, + False, + False, + ) + + # Format the results + schema_client = client.schema() + client.close() + + pdf = format_hbase_output( + results, + schema_client, + group_alerts=False, + truncated=True, + extract_color=False, + ) + + json_payload = {} + # Extract only the alert of interest + if "candid" in payload: + mask = pdf["i:candid"].astype(str) == str(payload["candid"]) + json_payload.update({"candid": str(payload["candid"])}) + pos_target = np.where(mask)[0][0] + else: + # pdf has been sorted in `format_hbase_output` + pdf = pdf.iloc[0:1] + pos_target = 0 + + json_payload.update( + { + "hdfsPath": pdf["d:hdfs_path"].to_numpy()[pos_target].split("8020")[1], + "kind": payload["kind"], + "objectId": pdf["i:objectId"].to_numpy()[pos_target], + } + ) + + if pdf.empty: + return send_file( + io.BytesIO(), + mimetype="image/png", + as_attachment=True, + download_name=filename, + ) + # Extract cutouts + user_config = extract_configuration("config.yml") + if output_format == "FITS": + json_payload.update({"return_type": "FITS"}) + r0 = requests.post("{}/api/v1/cutouts".format(user_config["CUTOUTAPIURL"]), json=json_payload) + cutout = io.BytesIO(r0.content) + elif output_format in ["PNG", "array"]: + json_payload.update({"return_type": "array"}) + r0 = requests.post("{}/api/v1/cutouts".format(user_config["CUTOUTAPIURL"]), json=json_payload) + cutout = json.loads(r0.content) + + # send the FITS file + if output_format == "FITS": + return send_file( + cutout, + mimetype="application/octet-stream", + as_attachment=True, + download_name=filename, + ) + # send the array + elif output_format == "array": + if payload["kind"] != "All": + return jsonify({"b:cutout{}_stampData".format(payload["kind"]): cutout[0]}) + else: + out = { + "b:cutoutScience_stampData": cutout[0], + "b:cutoutTemplate_stampData": cutout[1], + "b:cutoutDifference_stampData": cutout[2], + } + return jsonify(out) + + array = np.nan_to_num(np.array(cutout[0], dtype=float)) + if stretch == "sigmoid": + array = sigmoid_normalizer(array, 0, 1) + elif stretch is not None: + pmin = 0.5 + if "pmin" in payload: + pmin = float(payload["pmin"]) + pmax = 99.5 + if "pmax" in payload: + pmax = float(payload["pmax"]) + array = legacy_normalizer(array, stretch=stretch, pmin=pmin, pmax=pmax) + + if "convolution_kernel" in payload: + assert payload["convolution_kernel"] in ["gauss", "box"] + array = convolve(array, smooth=1, kernel=payload["convolution_kernel"]) + + # colormap + if "colormap" in payload: + colormap = getattr(cm, payload["colormap"]) + else: + colormap = lambda x: x # noqa: E731 + array = np.uint8(colormap(array) * 255) + + # Convert to PNG + data = Image.fromarray(array) + datab = io.BytesIO() + data.save(datab, format="PNG") + datab.seek(0) + return send_file( + datab, mimetype="image/png", as_attachment=True, download_name=filename + ) diff --git a/apps/utils/plotting.py b/apps/utils/plotting.py new file mode 100644 index 0000000..37ec3ba --- /dev/null +++ b/apps/utils/plotting.py @@ -0,0 +1,158 @@ +# Copyright 2024 AstroLab Software +# Author: Julien Peloton +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import numpy as np + +from astropy.convolution import Box2DKernel, Gaussian2DKernel +from astropy.convolution import convolve as astropy_convolve +from astropy.visualization import AsymmetricPercentileInterval, simple_norm + + +def sigmoid(img: list) -> list: + """Sigmoid function used for img_normalizer + + Parameters + ---------- + img: float array + a float array representing a non-normalized image + + Returns + ------- + out: float array + """ + # Compute mean and std of the image + img_mean, img_std = img.mean(), img.std() + # restore img to normal mean and std + img_normalize = (img - img_mean) / img_std + # image inversion + inv_norm = -img_normalize + # compute exponential of inv img + exp_norm = np.exp(inv_norm) + # perform sigmoid calculation and return it + return 1 / (1 + exp_norm) + + +def sigmoid_normalizer(img: list, vmin: float, vmax: float) -> list: + """Image normalisation between vmin and vmax using Sigmoid function + + Parameters + ---------- + img: float array + a float array representing a non-normalized image + + Returns + ------- + out: float array where data are bounded between vmin and vmax + """ + return (vmax - vmin) * sigmoid(img) + vmin + + +def legacy_normalizer(data: list, stretch="asinh", pmin=0.5, pmax=99.5) -> list: + """Old cutout normalizer which use the central pixel + + Parameters + ---------- + data: float array + a float array representing a non-normalized image + + Returns + ------- + out: float array where data are bouded between vmin and vmax + """ + size = len(data) + vmax = data[int(size / 2), int(size / 2)] + vmin = np.min(data) + 0.2 * np.median(np.abs(data - np.median(data))) + return _data_stretch( + data, vmin=vmin, vmax=vmax, pmin=pmin, pmax=pmax, stretch=stretch + ) + + +def convolve(image, smooth=3, kernel="gauss"): + """Convolve 2D image. Hacked from aplpy""" + if smooth is None and isinstance(kernel, str) and kernel in ["box", "gauss"]: + return image + + if smooth is not None and not np.isscalar(smooth): + raise ValueError( + "smooth= should be an integer - for more complex " + "kernels, pass an array containing the kernel " + "to the kernel= option" + ) + + # The Astropy convolution doesn't treat +/-Inf values correctly yet, so we + # convert to NaN here. + image_fixed = np.array(image, dtype=float, copy=True) + image_fixed[np.isinf(image)] = np.nan + + if isinstance(kernel, str): + if kernel == "gauss": + kernel = Gaussian2DKernel(smooth, x_size=smooth * 5, y_size=smooth * 5) + elif kernel == "box": + kernel = Box2DKernel(smooth, x_size=smooth * 5, y_size=smooth * 5) + else: + raise ValueError(f"Unknown kernel: {kernel}") + + return astropy_convolve(image, kernel, boundary="extend") + + +def _data_stretch( + image, + vmin=None, + vmax=None, + pmin=0.25, + pmax=99.75, + stretch="linear", + vmid: float = 10, + exponent=2, +): + """Hacked from aplpy""" + if vmin is None or vmax is None: + interval = AsymmetricPercentileInterval(pmin, pmax, n_samples=10000) + try: + vmin_auto, vmax_auto = interval.get_limits(image) + except IndexError: # no valid values + vmin_auto = vmax_auto = 0 + + if vmin is None: + # log.info("vmin = %10.3e (auto)" % vmin_auto) + vmin = vmin_auto + else: + pass + # log.info("vmin = %10.3e" % vmin) + + if vmax is None: + # log.info("vmax = %10.3e (auto)" % vmax_auto) + vmax = vmax_auto + else: + pass + # log.info("vmax = %10.3e" % vmax) + + if stretch == "arcsinh": + stretch = "asinh" + + normalizer = simple_norm( + image, + stretch=stretch, + power=exponent, + asinh_a=vmid, + min_cut=vmin, + max_cut=vmax, + clip=False, + ) + + data = normalizer(image, clip=True).filled(0) + data = np.nan_to_num(data) + # data = np.clip(data * 255., 0., 255.) + + return data # .astype(np.uint8) diff --git a/apps/utils/utils.py b/apps/utils/utils.py index c23f0d8..cabdf59 100644 --- a/apps/utils/utils.py +++ b/apps/utils/utils.py @@ -51,7 +51,7 @@ def download_cutout(objectId, candid, kind): config = extract_configuration("config.yml") r = requests.post( - "{}/api/v1/cutouts".format(config["CUTOUTAPIURL"]), + "{}/api/v1/cutouts".format(config["APIURL"]), json={ "objectId": objectId, "candid": candid, diff --git a/requirements.txt b/requirements.txt index 5501056..834c7f4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ fink-utils line_profiler requests pyarrow +matplotlib diff --git a/tests/api_cutouts_test.py b/tests/api_cutouts_test.py new file mode 100644 index 0000000..8cf4aae --- /dev/null +++ b/tests/api_cutouts_test.py @@ -0,0 +1,196 @@ +# Copyright 2022-2024 AstroLab Software +# Author: Julien Peloton +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import requests +import numpy as np + +from astropy.io import fits + +from PIL import Image + +import io +import sys + +APIURL = sys.argv[1] + + +def cutouttest( + objectId="ZTF21aaxtctv", + kind="Science", + stretch="sigmoid", + colormap="viridis", + pmin=0.5, + pmax=99.5, + convolution_kernel=None, + output_format="PNG", + candid=None, +): + """Perform a cutout search in the Science Portal using the Fink REST API""" + payload = { + "objectId": objectId, + "kind": kind, # Science, Template, Difference + "stretch": stretch, # sigmoid[default], linear, sqrt, power, log, asinh + "colormap": colormap, # Valid matplotlib colormap name (see matplotlib.cm). Default is grayscale. + "pmin": pmin, # The percentile value used to determine the pixel value of minimum cut level. Default is 0.5. No effect for sigmoid. + "pmax": pmax, # The percentile value used to determine the pixel value of maximum cut level. Default is 99.5. No effect for sigmoid. + "output-format": output_format, + } + + if candid is not None: + payload.update({"candid": candid}) + + # Convolve the image with a kernel (gauss or box). Default is None (not specified). + if convolution_kernel is not None: + payload.update({"convolution_kernel": convolution_kernel}) + + r = requests.post("{}/api/v1/cutouts".format(APIURL), json=payload) + + assert r.status_code == 200, r.content + + if output_format == "PNG": + # Format output in a DataFrame + data = Image.open(io.BytesIO(r.content)) + elif output_format == "FITS": + data = fits.open(io.BytesIO(r.content), ignore_missing_simple=True) + elif output_format == "array": + data = r.json()["b:cutout{}_stampData".format(kind)] + + return data + + +def test_png_cutout() -> None: + """ + Examples + -------- + >>> test_png_cutout() + """ + data = cutouttest() + + assert data.format == "PNG" + assert data.size == (63, 63) + + +def test_fits_cutout() -> None: + """ + Examples + -------- + >>> test_fits_cutout() + """ + data = cutouttest(output_format="FITS") + + assert len(data) == 1 + assert np.shape(data[0].data) == (63, 63) + + +def test_array_cutout() -> None: + """ + Examples + -------- + >>> test_array_cutout() + """ + data = cutouttest(output_format="array") + + assert np.shape(data) == (63, 63), data + assert isinstance(data, list) + + +def test_kind_cutout() -> None: + """ + Examples + -------- + >>> test_kind_cutout() + """ + data1 = cutouttest(kind="Science", output_format="array") + data2 = cutouttest(kind="Template", output_format="array") + data3 = cutouttest(kind="Difference", output_format="array") + + assert data1 != data2 + assert data2 != data3 + + +def test_pvalues_cutout() -> None: + """ + Examples + -------- + >>> test_pvalues_cutout() + """ + # pmin and pmax have no effect if stretch = sigmoid + data1 = cutouttest() + data2 = cutouttest(pmin=0.1, pmax=0.5) + + assert data1.getextrema() == data2.getextrema() + + # pmin and pmax have an effect otherwise + data1 = cutouttest() + data2 = cutouttest(pmin=0.1, pmax=0.5, stretch="linear") + + assert data1.getextrema() != data2.getextrema() + + +def test_stretch_cutout() -> None: + """ + Examples + -------- + >>> test_stretch_cutout() + """ + # pmin and pmax have no effect if stretch = sigmoid + data1 = cutouttest(stretch="sigmoid") + + for stretch in ["linear", "sqrt", "power", "log"]: + data2 = cutouttest(stretch=stretch) + assert data1.getextrema() != data2.getextrema() + + +def test_colormap_cutout() -> None: + """ + Examples + -------- + >>> test_colormap_cutout() + """ + data1 = cutouttest() + data2 = cutouttest(colormap="Greys") + + assert data1.getextrema() != data2.getextrema() + + +def test_convolution_kernel_cutout() -> None: + """ + Examples + -------- + >>> test_convolution_kernel_cutout() + """ + data1 = cutouttest() + data2 = cutouttest(convolution_kernel="gauss") + + assert data1.getextrema() != data2.getextrema() + + +def test_candid_cutout() -> None: + """ + Examples + -------- + >>> test_candid_cutout() + """ + data1 = cutouttest() + data2 = cutouttest(candid="1622215345315015012") + + assert data1.getextrema() != data2.getextrema() + + +if __name__ == "__main__": + """ Execute the test suite """ + import sys + import doctest + + sys.exit(doctest.testmod()[0])