Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions caterva2/services/plugins/image/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import ast
import pathlib

# Requirements
import jinja2
import numpy as np
import PIL.Image
from fastapi import Depends, FastAPI, Request, responses
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates

# Project
from caterva2.services import db
from caterva2.services.server import get_container, optional_user, resize_image
from caterva2.services.server import templates as sub_templates

app = FastAPI()
BASE_DIR = pathlib.Path(__file__).resolve().parent
templates = Jinja2Templates(directory=BASE_DIR / "templates")
templates.env.loader = jinja2.ChoiceLoader(
[
templates.env.loader, # Preserve the original loader
sub_templates.env.loader, # Add the sub-templates loader
]
)

name = "image" # Identifies the plugin
label = "Image"
contenttype = "image"


urlbase = None


def init(urlbase_):
global urlbase
urlbase = urlbase_


def url(path: str) -> str:
return f"{urlbase}/{path}"


def guess(path: pathlib.Path, meta) -> bool:
"""Does dataset (given path and metadata) seem of this content type?"""
if not hasattr(meta, "dtype"):
return False # not an array

dtype = meta.dtype
if dtype is None:
return False

# Structured dtype
if isinstance(dtype, str) and dtype.startswith("["):
dtype = eval(dtype) # TODO Make it safer

# Sometimes dtype is a tuple (e.g. ('<f8', (10,))), and this seems a safe way to handle it
try:
dtype = np.dtype(dtype)
except (ValueError, TypeError):
dtype = np.dtype(ast.literal_eval(dtype))
if dtype.kind != "u":
return False

shape = tuple(meta.shape)
if len(shape) == 3:
return True # grayscale

# RGB(A)
return len(shape) == 4 and shape[-1] in (3, 4)


@app.get("/display/{path:path}", response_class=HTMLResponse)
async def display(
request: Request,
# Path parameters
path: pathlib.Path,
user: db.User = Depends(optional_user),
):
ndim = 0
i = 0

array = await get_container(path, user)
height, width = (x for j, x in enumerate(array.shape[:3]) if j != ndim)

base = url(f"plugins/{name}")
href = f"{base}/image/{path}?{ndim=}&{i=}"

context = {
"href": href,
"shape": array.shape,
"width": width,
"height": height,
}
return templates.TemplateResponse(request, "display.html", context=context)


async def __get_image(path, user, ndim, i):
array = await get_container(path, user)
index = [slice(None) for x in array.shape]
index[ndim] = slice(i, i + 1, 1)
content = array[tuple(index)].squeeze()
if content.dtype.kind != "u":
content = (content - content.min()) / (content.max() - content.min()) # normalise to 0-1
content = (content * 255).astype(np.uint8)
return PIL.Image.fromarray(
content, mode="RGB" + ("A" if content.shape[-1] == 4 else "") if content.ndim == 3 else "L"
)


@app.get("/image/{path:path}")
async def image_file(
request: Request,
# Path parameters
path: pathlib.Path,
# Query parameters
ndim: int,
i: int,
width: int | None = None,
user: db.User = Depends(optional_user),
):
img = await __get_image(path, user, ndim, i)
img_file = resize_image(img, width)

def iterfile():
yield from img_file

return responses.StreamingResponse(iterfile(), media_type="image/png")
70 changes: 70 additions & 0 deletions caterva2/services/plugins/image/templates/display.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<div class="d-flex gap-2">
<select class="form-select w-auto" name="ndim" hx-on:change="update_ndim(this)">
{% for dim in shape[:3] %}
<option value="{{ loop.index0 }}" data-size="{{ dim }}">Dim {{ loop.index0 }} ({{ dim }})</option>
{% endfor %}
</select>
<input class="form-control w-auto" type="number" min="0" max="{{ shape.0 - 1}}" name="i" value="0"
hx-on:change="update_i(this)">
{% with id="image-spinner" %}
{% include 'includes/loading.html' %}
{% endwith %}
</div>

<a href="{{ href }}" target="blank_" id="image-original">{{ width }} x {{ height }} (original size)</a>
<br>
<img src="{{ href }}&width=512" id="display-image" onload="stopSpinner()">

<script>
function updateURL(src, options) {
const url = new URL(src);
for (const [key, value] of Object.entries(options)) {
url.searchParams.set(key, value);
}
return url.toString();
}

function updateImage(options) {
let img = document.getElementById('display-image');
document.getElementById('image-spinner').classList.add('htmx-request');
img.src = updateURL(img.src, options);
}

function update_i(input) {
const options = {i: input.value};
updateImage(options);

let link = document.getElementById('image-original');
if (link) {
link.href = updateURL(link.href, options);
}
}

function update_ndim(select) {
// Update image URL
const option = select.selectedOptions[0];
const options = {ndim: option.value, i: 0};
updateImage(options);

// Update max of input element
const size = option.getAttribute('data-size');
const input = document.querySelector('input[name="i"]');
input.setAttribute('max', size - 1);
input.value = 0;

// Update link to original size image
let link = document.getElementById('image-original');
if (link) {
link.href = updateURL(link.href, options);
const [h, w] = [...select.options].filter(opt => !opt.selected).map(opt => opt.dataset.size);
link.textContent = `${w} x ${h} (original size)`;
}
}

function stopSpinner() {
const spinner = document.getElementById('image-spinner');
if (spinner) {
spinner.classList.remove('htmx-request');
}
}
</script>
9 changes: 7 additions & 2 deletions caterva2/services/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2609,13 +2609,18 @@ def main():

# Register display plugins (delay module load)
try:
from .plugins import tomography # When used as module
from .plugins import image, tomography # When used as module
except ImportError:
from caterva2.services.plugins import tomography # When used as script
from caterva2.services.plugins import image, tomography # When used as script

# tomography
app.mount(f"/plugins/{tomography.name}", tomography.app)
plugins[tomography.contenttype] = tomography
tomography.init(settings.urlbase)
# image
app.mount(f"/plugins/{image.name}", image.app)
plugins[image.contenttype] = image
image.init(settings.urlbase)

# Mount media
media = settings.statedir / "media"
Expand Down