Skip to content

Commit 758edf5

Browse files
authored
Merge pull request #280 from ironArray/add_imagecontent
Add imagecontent
2 parents 4d271df + 840a35d commit 758edf5

File tree

3 files changed

+205
-2
lines changed

3 files changed

+205
-2
lines changed
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import ast
2+
import pathlib
3+
4+
# Requirements
5+
import jinja2
6+
import numpy as np
7+
import PIL.Image
8+
from fastapi import Depends, FastAPI, Request, responses
9+
from fastapi.responses import HTMLResponse
10+
from fastapi.templating import Jinja2Templates
11+
12+
# Project
13+
from caterva2.services import db
14+
from caterva2.services.server import get_container, optional_user, resize_image
15+
from caterva2.services.server import templates as sub_templates
16+
17+
app = FastAPI()
18+
BASE_DIR = pathlib.Path(__file__).resolve().parent
19+
templates = Jinja2Templates(directory=BASE_DIR / "templates")
20+
templates.env.loader = jinja2.ChoiceLoader(
21+
[
22+
templates.env.loader, # Preserve the original loader
23+
sub_templates.env.loader, # Add the sub-templates loader
24+
]
25+
)
26+
27+
name = "image" # Identifies the plugin
28+
label = "Image"
29+
contenttype = "image"
30+
31+
32+
urlbase = None
33+
34+
35+
def init(urlbase_):
36+
global urlbase
37+
urlbase = urlbase_
38+
39+
40+
def url(path: str) -> str:
41+
return f"{urlbase}/{path}"
42+
43+
44+
def guess(path: pathlib.Path, meta) -> bool:
45+
"""Does dataset (given path and metadata) seem of this content type?"""
46+
if not hasattr(meta, "dtype"):
47+
return False # not an array
48+
49+
dtype = meta.dtype
50+
if dtype is None:
51+
return False
52+
53+
# Structured dtype
54+
if isinstance(dtype, str) and dtype.startswith("["):
55+
dtype = eval(dtype) # TODO Make it safer
56+
57+
# Sometimes dtype is a tuple (e.g. ('<f8', (10,))), and this seems a safe way to handle it
58+
try:
59+
dtype = np.dtype(dtype)
60+
except (ValueError, TypeError):
61+
dtype = np.dtype(ast.literal_eval(dtype))
62+
if dtype.kind != "u":
63+
return False
64+
65+
shape = tuple(meta.shape)
66+
if len(shape) == 3:
67+
return True # grayscale
68+
69+
# RGB(A)
70+
return len(shape) == 4 and shape[-1] in (3, 4)
71+
72+
73+
@app.get("/display/{path:path}", response_class=HTMLResponse)
74+
async def display(
75+
request: Request,
76+
# Path parameters
77+
path: pathlib.Path,
78+
user: db.User = Depends(optional_user),
79+
):
80+
ndim = 0
81+
i = 0
82+
83+
array = await get_container(path, user)
84+
height, width = (x for j, x in enumerate(array.shape[:3]) if j != ndim)
85+
86+
base = url(f"plugins/{name}")
87+
href = f"{base}/image/{path}?{ndim=}&{i=}"
88+
89+
context = {
90+
"href": href,
91+
"shape": array.shape,
92+
"width": width,
93+
"height": height,
94+
}
95+
return templates.TemplateResponse(request, "display.html", context=context)
96+
97+
98+
async def __get_image(path, user, ndim, i):
99+
array = await get_container(path, user)
100+
index = [slice(None) for x in array.shape]
101+
index[ndim] = slice(i, i + 1, 1)
102+
content = array[tuple(index)].squeeze()
103+
if content.dtype.kind != "u":
104+
content = (content - content.min()) / (content.max() - content.min()) # normalise to 0-1
105+
content = (content * 255).astype(np.uint8)
106+
return PIL.Image.fromarray(
107+
content, mode="RGB" + ("A" if content.shape[-1] == 4 else "") if content.ndim == 3 else "L"
108+
)
109+
110+
111+
@app.get("/image/{path:path}")
112+
async def image_file(
113+
request: Request,
114+
# Path parameters
115+
path: pathlib.Path,
116+
# Query parameters
117+
ndim: int,
118+
i: int,
119+
width: int | None = None,
120+
user: db.User = Depends(optional_user),
121+
):
122+
img = await __get_image(path, user, ndim, i)
123+
img_file = resize_image(img, width)
124+
125+
def iterfile():
126+
yield from img_file
127+
128+
return responses.StreamingResponse(iterfile(), media_type="image/png")
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<div class="d-flex gap-2">
2+
<select class="form-select w-auto" name="ndim" hx-on:change="update_ndim(this)">
3+
{% for dim in shape[:3] %}
4+
<option value="{{ loop.index0 }}" data-size="{{ dim }}">Dim {{ loop.index0 }} ({{ dim }})</option>
5+
{% endfor %}
6+
</select>
7+
<input class="form-control w-auto" type="number" min="0" max="{{ shape.0 - 1}}" name="i" value="0"
8+
hx-on:change="update_i(this)">
9+
{% with id="image-spinner" %}
10+
{% include 'includes/loading.html' %}
11+
{% endwith %}
12+
</div>
13+
14+
<a href="{{ href }}" target="blank_" id="image-original">{{ width }} x {{ height }} (original size)</a>
15+
<br>
16+
<img src="{{ href }}&width=512" id="display-image" onload="stopSpinner()">
17+
18+
<script>
19+
function updateURL(src, options) {
20+
const url = new URL(src);
21+
for (const [key, value] of Object.entries(options)) {
22+
url.searchParams.set(key, value);
23+
}
24+
return url.toString();
25+
}
26+
27+
function updateImage(options) {
28+
let img = document.getElementById('display-image');
29+
document.getElementById('image-spinner').classList.add('htmx-request');
30+
img.src = updateURL(img.src, options);
31+
}
32+
33+
function update_i(input) {
34+
const options = {i: input.value};
35+
updateImage(options);
36+
37+
let link = document.getElementById('image-original');
38+
if (link) {
39+
link.href = updateURL(link.href, options);
40+
}
41+
}
42+
43+
function update_ndim(select) {
44+
// Update image URL
45+
const option = select.selectedOptions[0];
46+
const options = {ndim: option.value, i: 0};
47+
updateImage(options);
48+
49+
// Update max of input element
50+
const size = option.getAttribute('data-size');
51+
const input = document.querySelector('input[name="i"]');
52+
input.setAttribute('max', size - 1);
53+
input.value = 0;
54+
55+
// Update link to original size image
56+
let link = document.getElementById('image-original');
57+
if (link) {
58+
link.href = updateURL(link.href, options);
59+
const [h, w] = [...select.options].filter(opt => !opt.selected).map(opt => opt.dataset.size);
60+
link.textContent = `${w} x ${h} (original size)`;
61+
}
62+
}
63+
64+
function stopSpinner() {
65+
const spinner = document.getElementById('image-spinner');
66+
if (spinner) {
67+
spinner.classList.remove('htmx-request');
68+
}
69+
}
70+
</script>

caterva2/services/server.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2637,13 +2637,18 @@ def main():
26372637

26382638
# Register display plugins (delay module load)
26392639
try:
2640-
from .plugins import tomography # When used as module
2640+
from .plugins import image, tomography # When used as module
26412641
except ImportError:
2642-
from caterva2.services.plugins import tomography # When used as script
2642+
from caterva2.services.plugins import image, tomography # When used as script
26432643

2644+
# tomography
26442645
app.mount(f"/plugins/{tomography.name}", tomography.app)
26452646
plugins[tomography.contenttype] = tomography
26462647
tomography.init(settings.urlbase)
2648+
# image
2649+
app.mount(f"/plugins/{image.name}", image.app)
2650+
plugins[image.contenttype] = image
2651+
image.init(settings.urlbase)
26472652

26482653
# Mount media
26492654
media = settings.statedir / "media"

0 commit comments

Comments
 (0)