From 17d356d1f2299fde75f84ef220eec9b1ff063668 Mon Sep 17 00:00:00 2001 From: Richard Gerum <14153051+rgerum@users.noreply.github.com> Date: Mon, 18 Mar 2024 19:00:09 -0400 Subject: [PATCH] 3d_viewer can load content from zip file --- docs/source/_static/js/3d_viewer.mjs | 169 +++++++++++++++++++++------ docs/source/_static/js/load_numpy.js | 16 +-- saenopy/export_html.py | 139 ++++++++++++++++++++++ 3 files changed, 279 insertions(+), 45 deletions(-) create mode 100644 saenopy/export_html.py diff --git a/docs/source/_static/js/3d_viewer.mjs b/docs/source/_static/js/3d_viewer.mjs index ee9b9a3..8a73f6b 100644 --- a/docs/source/_static/js/3d_viewer.mjs +++ b/docs/source/_static/js/3d_viewer.mjs @@ -10,6 +10,79 @@ import { GUI } from "three/addons/libs/lil-gui.module.min.js"; import { loadNpy } from "./load_numpy.js"; import { cmaps } from "./colormaps.js"; + + import { + BlobWriter, + BlobReader, + ZipReader, +} from "https://unpkg.com/@zip.js/zip.js/index.js"; +// Creates a ZipReader object reading the zip content via `zipFileReader`, +// retrieves metadata (name, dates, etc.) of the first entry, retrieves its +// content via `helloWorldWriter`, and closes the reader. + +async function glob_to_canvas(blob) { + return new Promise((accept, reject) => { + // Assuming `blob` is your Blob object representing a JPEG file +const blobUrl = URL.createObjectURL(blob); + +// Create a new Image object +const img = new Image(); + +// Set up onload handler to draw the image to the canvas once it's loaded +img.onload = function() { + // Create a canvas element + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + // Set canvas dimensions to the image dimensions + canvas.width = img.width; + canvas.height = img.height; + + // Draw the image onto the canvas + ctx.drawImage(img, 0, 0); + + // Now you can work with the canvas or add it to the DOM + //document.body.appendChild(canvas); + accept(canvas); + + // Release the Blob URL to free up memory + URL.revokeObjectURL(blobUrl); +}; + +// Set the source of the image to the Blob URL +img.src = blobUrl; +}) +} + +const zip_entries = {} +async function get_file_from_zip(url, filename, return_type="blob") { + if(zip_entries[url] === undefined) { + async function get_entries(url) { + const zipReader = new ZipReader(new BlobReader(await (await fetch(url)).blob())); + const entries = await zipReader.getEntries(); + const entry_map = {} + for (let entry of entries) { + entry_map[entry.filename] = entry; + } + await zipReader.close(); + return entry_map + } + zip_entries[url] = get_entries(url); + } + const entry = (await zip_entries[url])[filename]; + if(!entry) + console.error("file", filename, "not found in", url); + + if(entry.filename === filename) { + const blob = await entry.getData(new BlobWriter()); + if(return_type === "url") + return URL.createObjectURL(blob); + if(return_type === "texture") + return new THREE.TextureLoader().load(URL.createObjectURL(blob)); + return blob; + } +} + const ccs_prefix = "saenopy_"; // Arrowhead geometry (cone) @@ -62,7 +135,7 @@ function add_logo(parentDom, params) { position: absolute; left: 0; top: 0; - width: ${params.logo_width}; + width: min(${params.logo_width}, 40%); }`); } @@ -174,13 +247,14 @@ function init_scene(dom_elem) { dom_elem.appendChild(canvas); dom_elem = canvas; } + dom_elem.style.display = "block"; scene = new THREE.Scene(); camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, - 1000, + 1500, ); scene.camera = camera; renderer = new THREE.WebGLRenderer({ alpha: true, canvas: dom_elem, antialias: true }); @@ -276,50 +350,74 @@ function pad_zero(num, places) { return String(num).padStart(places, "0"); } -function add_image(scene, params, data) { - let w = data.stacks.im_shape[0] * data.stacks.voxel_size[0]; - let h = data.stacks.im_shape[1] * data.stacks.voxel_size[1]; + +const pending = { + state: 'pending', +}; + +function getPromiseState(promise) { + // We put `pending` promise after the promise to test, + // which forces .race to test `promise` first + return Promise.race([promise, pending]).then( + (value) => { + if (value === pending) { + return value; + } + return { + state: 'resolved', + value + }; + }, + (reason) => ({ state: 'rejected', reason }) + ); +} + +async function add_image(scene, params) { + let w = params.data.stacks.im_shape[0] * params.data.stacks.voxel_size[0]; + let h = params.data.stacks.im_shape[1] * params.data.stacks.voxel_size[1]; let d = 0; // Image setup const imageGeometry = new THREE.PlaneGeometry(w, h); + // a black image texture to start const texture = new THREE.TextureLoader().load( - params.path + "/stack/000.jpg", + "data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=" ); const imageMaterial = new THREE.MeshBasicMaterial({ map: texture }); + imageMaterial.side = THREE.DoubleSide; let imagePlane = new THREE.Mesh(imageGeometry, imageMaterial); - //imagePlane.position.y = -d/2 + 0.2*d; // Custom Z position imagePlane.rotation.x = -Math.PI / 2; scene.add(imagePlane); + //const texture_await = get_textures_from_zip("data/stack/stack.zip"); + //let textures; + const textures = []; - for (let i = 0; i < data.stacks.z_slices_count; i++) { - if(params.pre_load_images) - textures.push(new THREE.TextureLoader().load(params.path + "/stack/" + pad_zero(i+1, 3) + ".jpg")); - else - textures.push(null); + for (let i = 0; i < params.data.stacks.z_slices_count; i++) { + textures.push(get_file_from_zip(params.data.path, "0/stack/" + params.data.stacks.channels[0] + "/" + pad_zero(i, 3) + ".jpg", "texture")); + textures[i].then((v) => {textures[i] = v}) } - function update() { + async function update() { if (params.image === "z-pos") { imagePlane.position.y = - (-data.stacks.im_shape[2] * data.stacks.voxel_size[2]) / 2 + - params.z * data.stacks.voxel_size[2]; + (-params.data.stacks.im_shape[2] * params.data.stacks.voxel_size[2]) / 2 + + params.z * params.data.stacks.voxel_size[2]; imagePlane.scale.x = 1; } else if (params.image === "floor") { imagePlane.position.y = - (-data.stacks.im_shape[2] * data.stacks.voxel_size[2]) / 2; + (-params.data.stacks.im_shape[2] * params.data.stacks.voxel_size[2]) / 2; imagePlane.scale.x = 1; } else { imagePlane.scale.x = 0; } const z = Math.floor(params.z); - if (!textures[z]) - textures[z] = new THREE.TextureLoader().load( - params.path + "/stack/" + pad_zero(Math.ceil(params.z) + 1, 3) + ".jpg", - ); - imagePlane.material.map = textures[z]; + if(textures[z].then === undefined) + imagePlane.material.map = await textures[z]; + else { + textures[z].then(update) + } } - update(); + update() return update; } @@ -363,12 +461,8 @@ async function add_test(scene, params) { needs_update = true; if (params.field !== "none") { try { - nodes = await loadNpy( - params.path + "/" + params.data.fields[params.field].nodes, - ); - vectors = await loadNpy( - params.path + "/" + params.data.fields[params.field].vectors, - ); + nodes = await loadNpy(await get_file_from_zip(params.data.path, "0/" + params.data.fields[params.field].nodes, "blob")); + vectors = await loadNpy(await get_file_from_zip(params.data.path, "0/" + params.data.fields[params.field].vectors, "blob")); } catch (e) {} } @@ -495,10 +589,10 @@ export async function init(initial_params) { const params = { scale: 1, cmap: "turbo", // ["turbo", "viridis"] - field: "none", // fitted deformations + field: "fitted deformations", // fitted deformations z: 0, cube: "field", // ["none", "stack", "field"] - image: "none", // ["none", "z-pos", "floor"] + image: "z-pos", // ["none", "z-pos", "floor"] background: "black", height: "400px", width: "auto", @@ -525,9 +619,11 @@ export async function init(initial_params) { add_logo(scene.renderer.domElement.parentElement, params); - const data = await (await fetch(initial_params.path + "/data.json")).json(); - params.data = data; - const update_image = (params.data.stacks ? add_image(scene, params, data) : () => {}); + if(params.data === undefined) { + const data = await (await fetch(initial_params.path + "/data.json")).json(); + params.data = data; + } + const update_image = (params.data.stacks ? await add_image(scene, params) : () => {}); if(params.mouse_control) { const controlsCam = new OrbitControls(camera, renderer.domElement); @@ -549,7 +645,6 @@ export async function init(initial_params) { await update_field(); update_cube(); } - // Animation loop animate(params, update_all); @@ -560,7 +655,7 @@ export async function init(initial_params) { window.gui = gui; const options = ["none"]; - for (let name in data.fields) { + for (let name in params.data.fields) { options.push(name); } if (options.length > 1) { @@ -568,7 +663,7 @@ export async function init(initial_params) { gui.add(params, "field", options).onChange(update_all); } if(options.length > 1) - gui.add(params, "cmap", ["turbo", "viridis"]).onChange(update_all); + gui.add(params, "cmap", Object.keys(cmaps)).onChange(update_all); const cube_options = ["none"] if(options.length > 1) cube_options.push("field") @@ -580,7 +675,7 @@ export async function init(initial_params) { if(options.length > 1) gui.add(params, "show_colormap").onChange(update_all); if(params.data.stacks) - gui.add(params, "z", 0, data.stacks.z_slices_count - 2, 1).onChange(update_all); + gui.add(params, "z", 0, params.data.stacks.z_slices_count - 1, 1).onChange(update_all); gui.close(); } diff --git a/docs/source/_static/js/load_numpy.js b/docs/source/_static/js/load_numpy.js index 4006762..36e98c0 100644 --- a/docs/source/_static/js/load_numpy.js +++ b/docs/source/_static/js/load_numpy.js @@ -1,6 +1,12 @@ export async function loadNpy(url) { - const response = await fetch(url); - const arrayBuffer = await response.arrayBuffer(); + let arrayBuffer; + if(typeof url === "string") { + const response = await fetch(url); + arrayBuffer = await response.arrayBuffer(); + } + else { + arrayBuffer = await url.arrayBuffer(); + } const dataView = new DataView(arrayBuffer); // Check magic number @@ -34,11 +40,5 @@ export async function loadNpy(url) { } else { throw new Error("Unsupported dtype. Only Uint8 is supported. Got" + dtype); } - return data; - return { - dtype: dtype, - shape: shape, - data: data, - }; } diff --git a/saenopy/export_html.py b/saenopy/export_html.py new file mode 100644 index 0000000..5adbd4b --- /dev/null +++ b/saenopy/export_html.py @@ -0,0 +1,139 @@ +import matplotlib.pyplot as plt +from matplotlib.colors import to_hex +from pathlib import Path +import saenopy +import numpy as np +from PIL import Image +import json +import zipfile +from io import BytesIO + +def export_cmaps(cmaps): + text = "export const cmaps = {\n" + + for name in cmaps: + cmap = plt.get_cmap("viridis") + + colors = [] + for i in range(256): + colors.append("0x" + to_hex(cmap(i))[1:]) + + text += f" \"{name}\": [" + ",".join(colors) + "],\n" + text = text[:-2] + text += "\n}" + return text + +#print(export_cmaps(["viridis", "turbo"])) + +def export_html(result: saenopy.Result, path, zip_filename="data.zip", stack_quality=75, stack_downsample=4, stack_downsample_z=2): + path = Path(path) + path.mkdir(exist_ok=True) + + with zipfile.ZipFile(path / zip_filename, 'w', zipfile.ZIP_DEFLATED) as zipFp: + + path_data = "0/" + def save(path, data, dtype): + new_data = np.zeros(data.shape, dtype=dtype) + new_data[:] = data + print(new_data.strides, new_data.shape) + + image_file = BytesIO() + np.save(image_file, new_data) + zipFp.writestr(path, image_file.getvalue()) + + data = {} + mesh = result.solvers[0].mesh + piv = result.mesh_piv[0] + save(path_data + "nodes_piv.npy", piv.nodes, np.float32) + save(path_data + "displacements_piv.npy", piv.displacements_measured, np.float32) + save(path_data + "nodes.npy", mesh.nodes, np.float32) + save(path_data + "displacements_target.npy", mesh.displacements_target, np.float32) + save(path_data + "displacements.npy", mesh.displacements, np.float32) + save(path_data + "forces.npy", -mesh.forces * mesh.regularisation_mask[:, None], np.float32) + + #path_stack = path_data + "stack" + #path_stack.mkdir(exist_ok=True) + + z_count = 0 + for z in range(result.get_data_structure()["z_slices_count"]): + if z % stack_downsample_z != 0: + continue + image_file = BytesIO() + + im = result.stacks[0].get_image(z, 0) + im = im - np.min(im) + im = im / np.max(im) * 255 + im = Image.fromarray(im.astype(np.uint8)) + if stack_downsample > 1: + im = im.resize((im.width//stack_downsample, im.height//stack_downsample), Image.Resampling.LANCZOS) + im.save(image_file, 'JPEG', quality=stack_quality) + zipFp.writestr(f"0/stack/0/{z//stack_downsample_z:03d}.jpg", image_file.getvalue()) + z_count += 1 + + im_shape = [int(x) for x in result.get_data_structure()["im_shape"]] + im_shape[2] = im_shape[2]//stack_downsample_z + voxel_size = result.get_data_structure()["voxel_size"] + voxel_size[2] = voxel_size[2] * stack_downsample_z + + data["path"] = zip_filename + + data["stacks"] = { + "channels": ["0"], + "z_slices_count": z_count, + "im_shape": tuple(im_shape), + "voxel_size": tuple(voxel_size), + } + data["fields"] = { + "measured deformations": {"nodes": "nodes_piv.npy", "vectors": "displacements_piv.npy", "unit": "µm", "factor": 1e6}, + "target deformations": {"nodes": "nodes.npy", "vectors": "displacements_target.npy", "unit": "µm", "factor": 1e6}, + "fitted deformations": {"nodes": "nodes.npy", "vectors": "displacements.npy", "unit": "µm", "factor": 1e6}, + "fitted forces": {"nodes": "nodes.npy", "vectors": "forces.npy", "unit": "nN", "factor": 1e9}, + } + data["time_point_count"] = result.get_data_structure()["time_point_count"] + + print(data) + + html = """ + +
+ + +