diff --git a/README.md b/README.md index 58286f7..6ecbb91 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,8 @@ Both endpoints take the following query parameters: * height * label * image +* color (true|false) +* planet (true|false) Width and height are in pixels, label will appear as white text in the lower left had corner of the image. diff --git a/thumbservice/common.py b/thumbservice/common.py index 6d741f4..589854d 100644 --- a/thumbservice/common.py +++ b/thumbservice/common.py @@ -1,4 +1,11 @@ import os +import logging +import numpy as np +from astropy.io import fits + +from PIL import Image + +logger = logging.getLogger(__name__) def get_temp_filename_prefix(pid=None): @@ -8,6 +15,96 @@ def get_temp_filename_prefix(pid=None): return f'pid{pid}-' +def rebin(arr, binning): + # Reduce resolution of data array to help with identifying planets + # make sure the array is an integer number of our binning + xlim = arr.shape[0] - arr.shape[0] % binning + ylim = arr.shape[1] - arr.shape[1] % binning + new_shape = (int(arr.shape[0]/binning), int(arr.shape[1]/binning)) + shape = (new_shape[0], arr.shape[0] // new_shape[0], + new_shape[1], arr.shape[1] // new_shape[1]) + return arr[0:xlim,0:ylim].reshape(shape).mean(-1).mean(1) + +def find_planet(data): + # Find the planet in the image to perform an approximate align and crop + binning = 3 + rebinned = rebin(data, binning) + max_coord = np.unravel_index(np.argmax(rebinned),rebinned.shape) + + # xw, yw = scale_coordinates(max_coord[0], max_coord[1], width, height) + x1, x2 = binning*max_coord[0]-300, binning*max_coord[0]+300 + y1, y2 = binning*max_coord[1]-300, binning*max_coord[1]+300 + return data[x1:x2,y1:y2] + +def planet_image_data(filename, colour=False): + # Open the fits file and scale the image appropriately + with fits.open(filename) as hdul: + img = hdul['sci'].data + header = hdul['sci'].header + + # Remove the background glow from scattered light + cutoff = 200 + img[img 255: + # scaled to 8 bit for jpg generation + vals = vals/np.max(vals)*255 + return vals + +def stack_images(images_to_stack): + rgb_cube = np.dstack(images_to_stack).astype(np.uint8) + return Image.fromarray(rgb_cube) + +def planet_image_to_jpg(filenames, output_path, **params): + ''' + Create a jpg of planet. If the size is less than 600x600, zoom in on the planet. If not use the full image size. + Resize the image to the specified height and width. + ''' + max_size = (params['height'], params['width']) + if params['height'] <= 600 and params['width'] <= 600: + zoom = True + else: + zoom = False + if len(filenames) >= 3: + stack = [] + for filename in filenames: + data = planet_image_data(filename=filename, colour=True) + if zoom: + stack.append(find_planet(data)) + else: + stack.append(data) + img = stack_images(stack) + img.convert('RGB') + + elif len(filenames) == 1: + data = planet_image_data(filenames[0]) + if zoom: + data = find_planet(data) + img = Image.fromarray(data.astype(np.uint8)) + + img.thumbnail(max_size) + img.save(output_path) + return + class Settings: def __init__(self, settings=None): self._settings = settings or {} diff --git a/thumbservice/tests.py b/thumbservice/tests.py index c81fc21..2b9d8c8 100644 --- a/thumbservice/tests.py +++ b/thumbservice/tests.py @@ -7,6 +7,8 @@ import pytest import requests from moto import mock_s3 +import numpy as np +from PIL import Image from thumbservice import common from thumbservice import thumbservice @@ -90,7 +92,82 @@ 'reduction_level': 0 }, ] - } + }, + 'narrow_frame' : + { + 'configuration_type': 'EXPOSE', + 'filename': 'ogg0m404-kb82-20190321-0273-e91.fits.fz', + 'id': 11245132, + 'url': 'http://file_url_1', + 'proposal_id': 'LCOEPO2018B-002', + 'request_id': 1756836, + 'primary_optical_element': 'H-Alpha', + }, + 'narrow_request_frames': { + 'count': 6, + 'results': [ + { + 'configuration_type': 'EXPOSE', + 'filename': 'ogg0m404-kb82-20190321-0273-e91.fits.fz', + 'id': 11245132, + 'url': 'http://file_url_1', + 'proposal_id': 'LCOEPO2018B-002', + 'request_id': 1756836, + 'primary_optical_element': 'H-Alpha', + 'reduction_level': 91 + }, + { + 'configuration_type': 'EXPOSE', + 'filename': 'ogg0m404-kb82-20190321-0273-e00.fits.fz', + 'id': 11245129, + 'url': 'http://file_url_2', + 'proposal_id': 'LCOEPO2018B-002', + 'request_id': 1756836, + 'primary_optical_element': 'H-Alpha', + 'reduction_level': 0 + }, + { + 'configuration_type': 'EXPOSE', + 'filename': 'ogg0m404-kb82-20190321-0272-e91.fits.fz', + 'id': 11245120, + 'url': 'http://file_url_3', + 'proposal_id': 'LCOEPO2018B-002', + 'request_id': 1756836, + 'primary_optical_element': 'OIII', + 'reduction_level': 91 + }, + { + 'configuration_type': 'EXPOSE', + 'filename': 'ogg0m404-kb82-20190321-0272-e00.fits.fz', + 'id': 11245119, + 'url': 'http://file_url_4', + 'proposal_id': 'LCOEPO2018B-002', + 'request_id': 1756836, + 'primary_optical_element': 'OIII', + 'reduction_level': 0 + }, + { + 'configuration_type': 'EXPOSE', + 'filename': 'ogg0m404-kb82-20190321-0271-e91.fits.fz', + 'id': 11245105, + 'url': 'http://file_url_5', + 'proposal_id': 'LCOEPO2018B-002', + 'request_id': 1756836, + 'primary_optical_element': 'SII', + 'reduction_level': 91 + }, + { + 'configuration_type': 'EXPOSE', + 'filename': 'ogg0m404-kb82-20190321-0271-e00.fits.fz', + 'id': 11245103, + 'url': 'http://file_url_6', + 'proposal_id': 'LCOEPO2018B-002', + 'request_id': 1756836, + 'primary_optical_element': 'SII', + 'reduction_level': 0 + }, + ] + } } @@ -115,6 +192,25 @@ def side_effect(*args, **kwargs): m.side_effect = side_effect +@pytest.fixture() +def mock_planet_image_to_jpg(): + def side_effect(*args, **kwargs): + Path(args[1]).touch() + m = thumbservice.planet_image_to_jpg = mock.MagicMock() + m.side_effect = side_effect + + +@pytest.fixture() +def mock_planet_image_data(): + def side_effect(*args, **kwargs): + data = np.zeros((601, 601)) + # fake planet + data[300, 300] = 255 + return data + + m = common.planet_image_data = mock.MagicMock() + m.side_effect = side_effect + def make_tmp_file(tmp_path, filename, suffix): path = tmp_path / Path(filename).with_suffix(suffix) path.touch() @@ -160,12 +256,12 @@ def thumbservice_client(): yield thumbservice.app.test_client() -@pytest.fixture +@pytest.fixture() def s3_client(): # This should be passed in to all test functions to mock out calls to aws with mock_s3(): config = boto3.session.Config(signature_version='s3v4') - s3 = boto3.client('s3', aws_access_key_id=TEST_ACCESS_KEY, aws_secret_access_key=TEST_SECRET_ACCESS_KEY, config=config) + s3 = boto3.client('s3', aws_access_key_id=TEST_ACCESS_KEY, aws_secret_access_key=TEST_SECRET_ACCESS_KEY, config=config, region_name="us-east-1") s3.create_bucket(Bucket=TEST_BUCKET) yield s3 @@ -224,6 +320,29 @@ def test_generate_color_thumbnail_successfully(thumbservice_client, requests_moc # All temp files should have been cleared out assert len(list(tmp_path.glob('*'))) == 0 +def test_generate_color_narrowband_thumbnail_successfully(thumbservice_client, requests_mock, s3_client, tmp_path): + frame = deepcopy(_test_data['narrow_frame']) + request_frames = deepcopy(_test_data['narrow_request_frames']) + requests_mock.get(f'{TEST_API_URL}frames/{frame["id"]}/', json=frame) + requests_mock.get(f'{TEST_API_URL}frames/?request_id={frame["request_id"]}&reduction_level=91', json=request_frames) + for request_frame in request_frames['results']: + requests_mock.get(request_frame['url'], content=b'I Am Image') + response1 = thumbservice_client.get(f'/{frame["id"]}/?color=true') + call_count_after_1 = requests_mock.call_count + response2 = thumbservice_client.get(f'/{frame["id"]}/?color=true') + call_count_after_2 = requests_mock.call_count + for response in [response1, response2]: + response_as_json = response.get_json() + assert response_as_json['propid'] == frame['proposal_id'] + assert 'url' in response_as_json + assert response.status_code == 200 + # The resource will have been created in s3 on the first call, on the second call less work needs to + # be done, including only 1 call to requests as opposed to the 5 on initial creation + assert call_count_after_1 == 5 + assert call_count_after_2 == 6 + # All temp files should have been cleared out + assert len(list(tmp_path.glob('*'))) == 0 + @pytest.mark.no_auto_mock_affineremap def test_image_align_fails_falls_back_to_original_image_list(thumbservice_client, requests_mock, tmp_path, s3_client): @@ -334,6 +453,16 @@ def test_cannot_generate_color_thumbnail_with_incomplete_frame_info(thumbservice assert 'Cannot generate thumbnail for given frame' in response.get_json()['message'] assert len(list(tmp_path.glob('*'))) == 0 +def test_all_filters_for_narrow_band_color_thumbnail_not_available(thumbservice_client, requests_mock, s3_client, tmp_path): + frame = deepcopy(_test_data['narrow_frame']) + request_frames = deepcopy(_test_data['narrow_request_frames']) + request_frames['results'][2]['primary_optical_element'] = 'V' + requests_mock.get(f'{TEST_API_URL}frames/{frame["id"]}/', json=frame) + requests_mock.get(f'{TEST_API_URL}frames/?request_id={frame["request_id"]}&reduction_level=91', json=request_frames) + response = thumbservice_client.get(f'/{frame["id"]}/?color=true') + assert response.status_code == 404 + assert b'Wrong combination of RVB frames' in response.data + assert len(list(tmp_path.glob('*'))) == 0 def test_frame_not_found(thumbservice_client, requests_mock, tmp_path, s3_client): frame_id = 6 @@ -371,3 +500,124 @@ def test_frame_basename_does_not_exist(thumbservice_client, requests_mock, tmp_p response = thumbservice_client.get('/some_frame_that_doesnt_exist/') assert response.status_code == 404 assert len(list(tmp_path.glob('*'))) == 0 + +def test_planet_color(thumbservice_client, requests_mock, s3_client, tmp_path,mock_planet_image_to_jpg): + frame = deepcopy(_test_data['frame']) + request_frames = deepcopy(_test_data['request_frames']) + requests_mock.get(f'{TEST_API_URL}frames/{frame["id"]}/', json=frame) + requests_mock.get(f'{TEST_API_URL}frames/?request_id={frame["request_id"]}&reduction_level=91', json=request_frames) + for request_frame in request_frames['results']: + requests_mock.get(request_frame['url'], content=b'I Am Image') + response1 = thumbservice_client.get(f'/{frame["id"]}/?planet=true&color=true') + call_count_after_1 = requests_mock.call_count + response2 = thumbservice_client.get(f'/{frame["id"]}/?planet=true&color=true') + call_count_after_2 = requests_mock.call_count + for response in [response1, response2]: + response_as_json = response.get_json() + assert response_as_json['propid'] == frame['proposal_id'] + assert 'url' in response_as_json + assert response.status_code == 200 + # The resource will have been created in s3 on the first call, on the second call less work needs to + # be done, including only 1 call to requests as opposed to the 5 on initial creation + assert call_count_after_1 == 5 + assert call_count_after_2 == 6 + # All temp files should have been cleared out + assert len(list(tmp_path.glob('*'))) == 0 + +def test_planet_black_and_white(thumbservice_client, requests_mock, s3_client, tmp_path, mock_planet_image_to_jpg): + frame = deepcopy(_test_data['frame']) + requests_mock.get(f'{TEST_API_URL}frames/{frame["id"]}/', json=frame) + requests_mock.get(frame['url'], content=b'I Am Image') + response1 = thumbservice_client.get(f'/{frame["id"]}/?planet=true') + call_count_after_1 = requests_mock.call_count + response2 = thumbservice_client.get(f'/{frame["id"]}/?planet=true') + call_count_after_2 = requests_mock.call_count + for response in [response1, response2]: + response_as_json = response.get_json() + assert response_as_json['propid'] == frame['proposal_id'] + assert 'url' in response_as_json + assert response.status_code == 200 + # The resource will have been created in s3 on the first call, on the second call less work needs to + # be done, including only 1 call to requests as opposed to the 2 on initial creation + assert call_count_after_1 == 2 + assert call_count_after_2 == 3 + # All temp files should have been cleared out + assert len(list(tmp_path.glob('*'))) == 0 + +def test_one_filter_for_planet_color_thumbnail_not_available(thumbservice_client, requests_mock, s3_client, tmp_path, mock_planet_image_to_jpg): + frame = deepcopy(_test_data['frame']) + request_frames = deepcopy(_test_data['request_frames']) + request_frames['results'][2]['primary_optical_element'] = 'H-Alpha' + requests_mock.get(f'{TEST_API_URL}frames/{frame["id"]}/', json=frame) + requests_mock.get(f'{TEST_API_URL}frames/?request_id={frame["request_id"]}&reduction_level=91', json=request_frames) + response = thumbservice_client.get(f'/{frame["id"]}/?color=true&planet=true') + assert response.status_code == 404 + assert b'Wrong combination of RVB frames' in response.data + assert len(list(tmp_path.glob('*'))) == 0 + +def test_planet_color_narrow_band(thumbservice_client, requests_mock, s3_client, tmp_path, mock_planet_image_to_jpg): + frame = deepcopy(_test_data['narrow_frame']) + request_frames = deepcopy(_test_data['narrow_request_frames']) + requests_mock.get(f'{TEST_API_URL}frames/{frame["id"]}/', json=frame) + requests_mock.get(f'{TEST_API_URL}frames/?request_id={frame["request_id"]}&reduction_level=91', json=request_frames) + for request_frame in request_frames['results']: + requests_mock.get(request_frame['url'], content=b'I Am Image') + response1 = thumbservice_client.get(f'/{frame["id"]}/?planet=true&color=true') + call_count_after_1 = requests_mock.call_count + response2 = thumbservice_client.get(f'/{frame["id"]}/?planet=true&color=true') + call_count_after_2 = requests_mock.call_count + for response in [response1, response2]: + response_as_json = response.get_json() + assert response_as_json['propid'] == frame['proposal_id'] + assert 'url' in response_as_json + assert response.status_code == 200 + # The resource will have been created in s3 on the first call, on the second call less work needs to + # be done, including only 1 call to requests as opposed to the 5 on initial creation + assert call_count_after_1 == 5 + assert call_count_after_2 == 6 + # All temp files should have been cleared out + assert len(list(tmp_path.glob('*'))) == 0 + +def test_planet_image_to_jpg_3_files(mock_planet_image_data, tmp_path): + filenames = ['file1.fits', 'file2.fits', 'file3.fits'] + common.planet_image_to_jpg(filenames, tmp_path / 'test.jpg', height=601, width=601) + + imagefiles = list(tmp_path.glob('*')) + assert len(imagefiles) == 1 + assert imagefiles[0].name == 'test.jpg' + # Test we have a RGB image + img = Image.open(imagefiles[0]) + assert img.mode == 'RGB' + +def test_planet_image_to_jpg_1_file(mock_planet_image_data, tmp_path): + filenames = ['file1.fits',] + common.planet_image_to_jpg(filenames, tmp_path / 'test.jpg', height=601, width=601) + + imagefiles = list(tmp_path.glob('*')) + assert len(imagefiles) == 1 + assert imagefiles[0].name == 'test.jpg' + # Test we have a greyscale image + img = Image.open(imagefiles[0]) + assert img.mode == 'L' + +def test_planet_image_to_jpg_zoom(mock_planet_image_data, tmp_path): + filenames = ['file1.fits',] + common.planet_image_to_jpg(filenames, tmp_path / 'test.jpg', height=300, width=300) + + imagefiles = list(tmp_path.glob('*')) + assert len(imagefiles) == 1 + assert imagefiles[0].name == 'test.jpg' + # Test we have zoomed in image + img = Image.open(imagefiles[0]) + assert img.size == (300, 300) + +def test_planet_image_to_jpg_3_files_zoom(mock_planet_image_data, tmp_path): + filenames = ['file1.fits', 'file2.fits', 'file3.fits'] + common.planet_image_to_jpg(filenames, tmp_path / 'test.jpg', height=300, width=300) + + imagefiles = list(tmp_path.glob('*')) + assert len(imagefiles) == 1 + assert imagefiles[0].name == 'test.jpg' + # Test we have a RGB image + img = Image.open(imagefiles[0]) + assert img.size == (300, 300) \ No newline at end of file diff --git a/thumbservice/thumbservice.py b/thumbservice/thumbservice.py index de86757..657e270 100755 --- a/thumbservice/thumbservice.py +++ b/thumbservice/thumbservice.py @@ -12,8 +12,10 @@ from fits2image.conversions import fits_to_jpg from fits_align.ident import make_transforms from fits_align.align import affineremap +from PIL import Image +from numpy import uint8 -from thumbservice.common import settings, get_temp_filename_prefix +from thumbservice.common import settings, get_temp_filename_prefix, planet_image_to_jpg app = Flask(__name__, static_folder='static') @@ -122,6 +124,10 @@ def convert_to_jpg(paths, key, **params): fits_to_jpg(paths, jpg_path, **params) return jpg_path +def convert_to_planet_jpg(paths, key, **params): + jpg_path = f'{unique_temp_path_start()}{key}' + planet_image_to_jpg(paths, jpg_path, **params) + return jpg_path def get_s3_client(): config = boto3.session.Config(region_name=settings.AWS_DEFAULT_REGION, signature_version='s3v4', s3={'addressing_style': 'virtual'}) @@ -172,19 +178,29 @@ def frames_for_requestnum(request_id, request, reduction_level): def rvb_frames(frames): - FILTERS_FOR_COLORS = { - 'red': ['R', 'rp'], - 'visual': ['V'], - 'blue': ['B'], + FILTERS = { + 'red': ['r', 'rp','ip','h-alpha'], + 'visual': ['v','oiii'], + 'blue': ['b','gp','sii'], } + NARROW = ['h-alpha','oiii','sii'] + selected_frames = [] + narrow_check = [] for color in ['red', 'visual', 'blue']: try: selected_frames.append( - next(f for f in frames if f['primary_optical_element'] in FILTERS_FOR_COLORS[color]) + next(f for f in frames if f['primary_optical_element'].lower() in FILTERS[color]) ) except StopIteration: raise ThumbnailAppException('RVB frames not found', status_code=404) + + narrow_check = [f['primary_optical_element'].lower() for f in selected_frames if f['primary_optical_element'].lower() in NARROW] + if len(narrow_check) == 3 and (sorted(narrow_check) == NARROW): + pass + elif len(narrow_check) != 0: + raise ThumbnailAppException('Wrong combination of RVB frames', status_code=404) + return selected_frames @@ -227,7 +243,6 @@ def set(self, paths): def all_paths(self): return list(self._all_paths) - def generate_thumbnail(frame, request): params = { 'width': int(request.args.get('width', 200)), @@ -237,6 +252,7 @@ def generate_thumbnail(frame, request): 'median': request.args.get('median', 'false') != 'false', 'percentile': float(request.args.get('percentile', 99.5)), 'quality': int(request.args.get('quality', 80)), + 'planet': request.args.get('planet', 'false') != 'false', } key = key_for_jpeg(frame['id'], **params) if key_exists(key): @@ -251,8 +267,13 @@ def generate_thumbnail(frame, request): # Color thumbnails can only be generated on rlevel 91 images reqnum_frames = frames_for_requestnum(frame['request_id'], request, reduction_level=91) paths.set([save_temp_file(frame) for frame in rvb_frames(reqnum_frames)]) - paths.set(reproject_files(paths.paths[0], paths.paths)) - jpg_path = convert_to_jpg(paths.paths, key, **params) + if not params['planet']: + # Because there are no stars in the image, we can't align planet images this way + paths.set(reproject_files(paths.paths[0], paths.paths)) + if not params['planet']: + jpg_path = convert_to_jpg(paths.paths, key, **params) + else: + jpg_path = convert_to_planet_jpg(paths.paths, key, **params) upload_to_s3(key, jpg_path) finally: # Cleanup actions