Skip to content

Commit

Permalink
Add COG processing for large images
Browse files Browse the repository at this point in the history
  • Loading branch information
danlamanna committed May 30, 2024
1 parent 9e9d2b2 commit 76748d3
Show file tree
Hide file tree
Showing 8 changed files with 157 additions and 57 deletions.
113 changes: 90 additions & 23 deletions isic/ingest/models/accession.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import io
import logging
from mimetypes import guess_type
from pathlib import Path
import subprocess
import tempfile
from uuid import uuid4

Expand Down Expand Up @@ -33,6 +35,10 @@

logger = logging.getLogger(__name__)

# The number of square pixels at which an image is stored as a
# cloud optimized geotiff.
IMAGE_COG_THRESHOLD: int = 100_000_000


class Approx(Transform):
lookup_name = "approx"
Expand Down Expand Up @@ -307,6 +313,11 @@ def __str__(self) -> str:
RemappedField("rcm_case_id", "rcm_case", "private_rcm_case_id", RcmCase),
]

@property
def requires_cog(self):
if self.width and self.height:
return self.width * self.height > IMAGE_COG_THRESHOLD

@property
def published(self):
return hasattr(self, "image")
Expand All @@ -333,6 +344,80 @@ def metadata_keys():
field.name for field in Accession._meta.fields if hasattr(AccessionMetadata, field.name)
]

def _save_blob_from_pil_image(self, img: PIL.Image.Image):
if img.height * img.width < IMAGE_COG_THRESHOLD:
with tempfile.SpooledTemporaryFile() as stripped_blob_stream:
img.save(stripped_blob_stream, format="JPEG")

stripped_blob_stream.seek(0, io.SEEK_END)
stripped_blob_size = stripped_blob_stream.tell()
stripped_blob_stream.seek(0)

self.blob_name = f"{uuid4()}.jpg"
self.blob = InMemoryUploadedFile(
file=stripped_blob_stream,
field_name=None,
name=self.blob_name,
content_type="image/jpeg",
size=stripped_blob_size,
charset=None,
)
self.blob_size = stripped_blob_size
self.height = img.height
self.width = img.width

self.save(update_fields=["blob_name", "blob", "blob_size", "height", "width"])
else:
from rio_cogeo import cog_translate

with (
tempfile.NamedTemporaryFile() as stripped_blob_stream,
tempfile.NamedTemporaryFile(delete=False) as cog_stream,
):
img.save(stripped_blob_stream, format="PNG")

cog_translate(
source=stripped_blob_stream.name,
dst_path=cog_stream.name,
dst_kwargs={
"driver": "GTiff",
"interleave": "pixel",
"tiled": True,
"blockxsize": 512,
"blockysize": 512,
"compress": "DEFLATE",
"BIGTIFF": "IF_SAFER",
},
# to do
quiet=False,
)

# run exiftool -ModelTransform= on the file via subprocess
# subprocess.run(
# [ # noqa: S607, S603
# "exiftool",
# "-ModelTransform=",
# cog_stream.name,
# ],
# check=True,
# )

self.blob_size = Path(cog_stream.name).stat().st_size
with Path(cog_stream.name).open("rb") as cog_stream:
self.blob_name = f"{uuid4()}.tif"
self.blob = InMemoryUploadedFile(
file=cog_stream,
field_name=None,
name=self.blob_name,
content_type="image/tiff",
size=self.blob_size,
charset=None,
)
self.height = img.height
self.width = img.width

self.save(update_fields=["blob_name", "blob", "blob_size", "height", "width"])

def generate_blob(self):
"""
Generate `blob` and set `blob_size`, `height`, `width`.
Expand Down Expand Up @@ -368,29 +453,10 @@ def generate_blob(self):
raise

# Strip any alpha channel
img = img.convert("RGB")

with tempfile.SpooledTemporaryFile() as stripped_blob_stream:
img.save(stripped_blob_stream, format="JPEG")

stripped_blob_stream.seek(0, io.SEEK_END)
stripped_blob_size = stripped_blob_stream.tell()
stripped_blob_stream.seek(0)
if blob_mime_type == "image/jpeg":
img = img.convert("RGB")

self.blob_name = f"{uuid4()}.jpg"
self.blob = InMemoryUploadedFile(
file=stripped_blob_stream,
field_name=None,
name=self.blob_name,
content_type="image/jpeg",
size=stripped_blob_size,
charset=None,
)
self.blob_size = stripped_blob_size
self.height = img.height
self.width = img.width

self.save(update_fields=["blob_name", "blob", "blob_size", "height", "width"])
self._save_blob_from_pil_image(img)

self.generate_thumbnail()

Expand All @@ -409,8 +475,9 @@ def generate_blob(self):
self.status = AccessionStatus.SUCCEEDED
self.save(update_fields=["status"])

# TODO
def generate_thumbnail(self) -> None:
with self.blob.open() as blob_stream:
with Accession.objects.order_by("created").first().blob.open() as blob_stream:
img: PIL.Image.Image = PIL.Image.open(blob_stream)
# Load the image so the stream can be closed
img.load()
Expand Down
16 changes: 8 additions & 8 deletions isic/ingest/templates/ingest/accession_cog_viewer.html
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
{% extends 'core/base.html' %}
{% load static %}

{% load static %}

{% block content %}
<div id="image" style="width:100%; height:800px;"></div>

<div id="image" style="width:100%; height:400px;"></div>

<script type="module" src="{% static 'core/dist/cog.js' %}"></script>

<script type="text/javascript">
initializeCogViewer('{{ accession.blob.url|safe }}');
</script>
<script src="{% static 'core/dist/cog.js' %}"></script>

<script type="text/javascript">
initializeCogViewer('{{ accession.blob.url|safe }}',
{{ accession.width }},
{{ accession.height }});
</script>
{% endblock %}
9 changes: 8 additions & 1 deletion isic/ingest/templates/ingest/partials/accession.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,14 @@
{% if PLACEHOLDER_IMAGES %}
<img @mouseenter="hovered = true" @click="open = true" src="https://picsum.photos/seed/{{accession.id}}/256">
{% else %}
<img @mouseenter="hovered = true" @click="open = true" src="{{ accession.thumbnail_256.url }}" />
{% if accession.requires_cog %}
<a href="{% url 'accession-cog-viewer' accession.id %}">
<img src="{{ accession.thumbnail_256.url }}" />
</a>
{% else %}
<img @mouseenter="hovered = true" @click="open = true" src="{{ accession.thumbnail_256.url }}" />
{% endif %}

{% endif %}
</a>

Expand Down
18 changes: 1 addition & 17 deletions isic/ingest/views/accession.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,7 @@
from collections import defaultdict
import itertools

from django.contrib import messages
from django.contrib.admin.views.decorators import staff_member_required
from django.contrib.humanize.templatetags.humanize import intcomma
from django.core.exceptions import ValidationError
from django.core.paginator import Paginator
from django.db.models import Count, Prefetch, Q
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, render
from django.urls.base import reverse

from isic.core.permissions import needs_object_permission
from isic.ingest.forms import MergeCohortForm
from isic.ingest.models import Cohort
from isic.ingest.models.accession import Accession, AccessionStatus
from isic.ingest.models.contributor import Contributor
from isic.ingest.services.cohort import cohort_merge, cohort_publish_initialize
from isic.ingest.views import make_breadcrumbs
from isic.ingest.models.accession import Accession


@staff_member_required
Expand Down
46 changes: 46 additions & 0 deletions node-src/cog.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import GeoTIFF from 'ol/source/GeoTIFF.js';
import Map from 'ol/Map.js';
import Projection from 'ol/proj/Projection.js';
import TileLayer from 'ol/layer/WebGLTile.js';
import View from 'ol/View.js';
import { getCenter } from 'ol/extent.js';

// TODO: figure out how to override getOrigin, see
// https://github.com/geotiffjs/geotiff.js/blob/da936684e30ef994f1d0d1e2da844b0e9a6c3cd0/src/geotiffimage.js#L825.
// This will obviate the need for exif stripping the model transformation tag.

function initializeCogViewer(url, width, height) {
const extent = [0, 0, width, height];

const projection = new Projection({
code: 'custom',
units: 'pixels',
extent: extent,
});

const geotiff = new GeoTIFF({
sources: [
{
url: url,
nodata: 0,
},
],
});

const map = new Map({
target: 'image',
layers: [
new TileLayer({
source: geotiff,
}),
],
view: new View({
projection: projection,
center: getCenter(extent),
extent: extent,
zoom: 1,
}),
});
}

window.initializeCogViewer = initializeCogViewer;
9 changes: 2 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,7 @@
},
"cog": {
"source": "./node-src/cog.mjs",
"distDir": "./isic/core/static/core/dist",
"context": "browser",
"outputFormat": "global"
"distDir": "./isic/core/static/core/dist"
}
},
"browserslist": "> 0.5%",
Expand All @@ -45,8 +43,5 @@
},
"dependencies": {
"ol": "^9.2.4"
},
"resolutions": {
"ol/**/@petamoriken/float16": "3.4.7"
}
}
}
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
"pyparsing",
"python-magic",
"requests",
"rio-cogeo",
"sentry-sdk[pure_eval]",
"tenacity",
"zipfile-deflate64",
Expand Down
2 changes: 1 addition & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -790,7 +790,7 @@
"@parcel/utils" "2.12.0"
nullthrows "^1.1.1"

"@petamoriken/float16@3.4.7", "@petamoriken/float16@^3.4.7":
"@petamoriken/float16@^3.4.7":
version "3.4.7"
resolved "https://registry.yarnpkg.com/@petamoriken/float16/-/float16-3.4.7.tgz#63ce6cb698881bca0fc272807196d497615c95ea"
integrity sha512-Mir0MAKxg5v6BUIg9SI5VAyrIa/3uptf7aPyvPhHNh0RMYMevrWbaLrsVSZ1f92C39hWd8v7GrjvvOT+WG5VUQ==
Expand Down

0 comments on commit 76748d3

Please sign in to comment.