Skip to content

Commit

Permalink
Add button for copying and saving images (#7156)
Browse files Browse the repository at this point in the history
### What
* Closes #6891

<img width="302" alt="Screenshot 2024-08-12 at 19 42 55"
src="https://github.com/user-attachments/assets/6f243946-050f-45c3-b5b6-b46dd18ec500">

Saving an image now also works on web, which it didn't do in 0.17

### Checklist
* [x] I have read and agree to [Contributor
Guide](https://github.com/rerun-io/rerun/blob/main/CONTRIBUTING.md) and
the [Code of
Conduct](https://github.com/rerun-io/rerun/blob/main/CODE_OF_CONDUCT.md)
* [x] I've included a screenshot or gif (if applicable)
* [x] I have tested the web demo (if applicable):
* Using examples from latest `main` build:
[rerun.io/viewer](https://rerun.io/viewer/pr/7156?manifest_url=https://app.rerun.io/version/main/examples_manifest.json)
* Using full set of examples from `nightly` build:
[rerun.io/viewer](https://rerun.io/viewer/pr/7156?manifest_url=https://app.rerun.io/version/nightly/examples_manifest.json)
* [x] The PR title and labels are set such as to maximize their
usefulness for the next release's CHANGELOG
* [x] If applicable, add a new check to the [release
checklist](https://github.com/rerun-io/rerun/blob/main/tests/python/release_checklist)!
* [x] If have noted any breaking changes to the log API in
`CHANGELOG.md` and the migration guide

- [PR Build Summary](https://build.rerun.io/pr/7156)
- [Recent benchmark results](https://build.rerun.io/graphs/crates.html)
- [Wasm size tracking](https://build.rerun.io/graphs/sizes.html)

To run all checks from `main`, comment on the PR with `@rerun-bot
full-check`.
  • Loading branch information
emilk authored Aug 13, 2024
1 parent 0d88399 commit c25de89
Show file tree
Hide file tree
Showing 21 changed files with 697 additions and 349 deletions.
4 changes: 3 additions & 1 deletion Cargo.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4464,7 +4464,6 @@ dependencies = [
"re_viewer_context",
"rfd",
"unindent",
"wasm-bindgen-futures",
]

[[package]]
Expand Down Expand Up @@ -5340,6 +5339,7 @@ dependencies = [
"egui-wgpu",
"egui_extras",
"egui_tiles",
"emath",
"glam",
"half 2.3.1",
"image",
Expand Down Expand Up @@ -5367,11 +5367,13 @@ dependencies = [
"re_types",
"re_types_core",
"re_ui",
"rfd",
"serde",
"slotmap",
"smallvec",
"thiserror",
"uuid",
"wasm-bindgen-futures",
"wgpu",
]

Expand Down
36 changes: 36 additions & 0 deletions crates/store/re_types/src/datatypes/pixel_format_ext.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use crate::image::rgb_from_yuv;

use super::{ChannelDatatype, ColorModel, PixelFormat};

impl PixelFormat {
Expand Down Expand Up @@ -43,4 +45,38 @@ impl PixelFormat {
Self::NV12 | Self::YUY2 => ChannelDatatype::U8,
}
}

/// Random-access decoding of a specific pixel of an image.
///
/// Return `None` if out-of-range.
#[inline]
pub fn decode_yuv_at(&self, buf: &[u8], [w, h]: [u32; 2], [x, y]: [u32; 2]) -> Option<[u8; 3]> {
match self {
Self::NV12 => {
let uv_offset = w * h;
let luma = *buf.get((y * w + x) as usize)?;
let u = *buf.get((uv_offset + (y / 2) * w + x) as usize)?;
let v = *buf.get((uv_offset + (y / 2) * w + x) as usize + 1)?;
Some([luma, u, v])
}

Self::YUY2 => {
let index = ((y * w + x) * 2) as usize;
if x % 2 == 0 {
Some([*buf.get(index)?, *buf.get(index + 1)?, *buf.get(index + 3)?])
} else {
Some([*buf.get(index)?, *buf.get(index - 1)?, *buf.get(index + 1)?])
}
}
}
}

/// Random-access decoding of a specific pixel of an image.
///
/// Return `None` if out-of-range.
#[inline]
pub fn decode_rgb_at(&self, buf: &[u8], [w, h]: [u32; 2], [x, y]: [u32; 2]) -> Option<[u8; 3]> {
let [y, u, v] = self.decode_yuv_at(buf, [w, h], [x, y])?;
Some(rgb_from_yuv(y, u, v))
}
}
28 changes: 28 additions & 0 deletions crates/store/re_types/src/image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -255,3 +255,31 @@ fn test_find_non_empty_dim_indices() {
expect(&[1, 1, 3], &[0, 1, 2]);
expect(&[1, 1, 3, 1], &[2, 3]);
}

// ----------------------------------------------------------------------------

/// Returns sRGB from YUV color.
///
/// This conversion mirrors the function of the same name in `crates/viewer/re_renderer/shader/decodings.wgsl`
///
/// Specifying the color standard should be exposed in the future [#3541](https://github.com/rerun-io/rerun/pull/3541)
pub fn rgb_from_yuv(y: u8, u: u8, v: u8) -> [u8; 3] {
let (y, u, v) = (y as f32, u as f32, v as f32);

// rescale YUV values
let y = (y - 16.0) / 219.0;
let u = (u - 128.0) / 224.0;
let v = (v - 128.0) / 224.0;

// BT.601 (aka. SDTV, aka. Rec.601). wiki: https://en.wikipedia.org/wiki/YCbCr#ITU-R_BT.601_conversion
let r = y + 1.402 * v;
let g = y - 0.344 * u - 0.714 * v;
let b = y + 1.772 * u;

// BT.709 (aka. HDTV, aka. Rec.709). wiki: https://en.wikipedia.org/wiki/YCbCr#ITU-R_BT.709_conversion
// let r = y + 1.575 * v;
// let g = y - 0.187 * u - 0.468 * v;
// let b = y + 1.856 * u;

[(255.0 * r) as u8, (255.0 * g) as u8, (255.0 * b) as u8]
}
5 changes: 0 additions & 5 deletions crates/viewer/re_data_ui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,3 @@ itertools.workspace = true
nohash-hasher.workspace = true
rfd.workspace = true
unindent.workspace = true


# web
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen-futures.workspace = true
177 changes: 67 additions & 110 deletions crates/viewer/re_data_ui/src/blob.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
use re_log::ResultExt;
use re_renderer::renderer::ColormappedTexture;
use re_types::components::{Blob, MediaType};
use re_ui::{list_item::PropertyContent, UiExt as _};
use re_viewer_context::{gpu_bridge::image_to_gpu, UiLayout};
use re_viewer_context::UiLayout;

use crate::{image::texture_preview_ui, EntityDataUi};
use crate::{image::image_preview_ui, EntityDataUi};

impl EntityDataUi for Blob {
fn entity_data_ui(
Expand All @@ -27,8 +25,6 @@ impl EntityDataUi for Blob {
// This can also help a user debug if they log the contents of `.png` file with a `image/jpeg` `MediaType`.
let media_type = MediaType::guess_from_data(self);

let texture = blob_as_texture(ctx, query, entity_path, row_id, self, media_type.as_ref());

if ui_layout.is_single_line() {
ui.horizontal(|ui| {
ui.label(compact_size_string);
Expand All @@ -38,9 +34,16 @@ impl EntityDataUi for Blob {
.on_hover_text("Media type (MIME) based on magic header bytes");
}

if let (Some(render_ctx), Some(texture)) = (ctx.render_ctx, texture) {
texture_preview_ui(render_ctx, ui, ui_layout, entity_path, texture);
}
blob_preview_and_save_ui(
ctx,
ui,
ui_layout,
query,
entity_path,
row_id,
self,
media_type.as_ref(),
);
});
} else {
let all_digits_size_string = format!("{} B", re_format::format_uint(self.len()));
Expand All @@ -64,123 +67,77 @@ impl EntityDataUi for Blob {
.on_hover_text("Failed to detect media type (Mime) from magic header bytes");
}

if let (Some(render_ctx), Some(texture)) = (ctx.render_ctx, texture) {
texture_preview_ui(render_ctx, ui, ui_layout, entity_path, texture);
}

if ui_layout != UiLayout::Tooltip {
let text = if cfg!(target_arch = "wasm32") {
"Download blob…"
} else {
"Save blob to file…"
};
if ui.button(text).clicked() {
let mut file_name = entity_path
.last()
.map_or("blob", |name| name.unescaped_str())
.to_owned();

if let Some(file_extension) =
media_type.as_ref().and_then(|mt| mt.file_extension())
{
file_name.push('.');
file_name.push_str(file_extension);
}

save_blob(ctx, file_name, "Save blob".to_owned(), self.clone())
.ok_or_log_error();
}
}
blob_preview_and_save_ui(
ctx,
ui,
ui_layout,
query,
entity_path,
row_id,
self,
media_type.as_ref(),
);
}
}
}

fn blob_as_texture(
#[allow(clippy::too_many_arguments)]
pub fn blob_preview_and_save_ui(
ctx: &re_viewer_context::ViewerContext<'_>,
ui: &mut egui::Ui,
ui_layout: UiLayout,
query: &re_chunk_store::LatestAtQuery,
entity_path: &re_log_types::EntityPath,
row_id: Option<re_chunk_store::RowId>,
blob: &Blob,
blob_row_id: Option<re_chunk_store::RowId>,
blob: &[u8],
media_type: Option<&MediaType>,
) -> Option<ColormappedTexture> {
let render_ctx = ctx.render_ctx?;
let debug_name = entity_path.to_string();

let image = row_id.and_then(|row_id| {
) {
let image = blob_row_id.and_then(|row_id| {
ctx.cache
.entry(|c: &mut re_viewer_context::ImageDecodeCache| {
c.entry(row_id, blob, media_type.as_ref().map(|mt| mt.as_str()))
})
.ok()
})?;
let image_stats = ctx
.cache
.entry(|c: &mut re_viewer_context::ImageStatsCache| c.entry(&image));
let annotations = crate::annotations(ctx, query, entity_path);
image_to_gpu(render_ctx, &debug_name, &image, &image_stats, &annotations).ok()
}

#[allow(clippy::needless_pass_by_ref_mut)] // `app` is only used on native
#[allow(clippy::unnecessary_wraps)] // cannot return error on web
fn save_blob(
#[allow(unused_variables)] ctx: &re_viewer_context::ViewerContext<'_>, // only used on native
file_name: String,
title: String,
blob: Blob,
) -> anyhow::Result<()> {
re_tracing::profile_function!();
});

// Web
#[cfg(target_arch = "wasm32")]
{
wasm_bindgen_futures::spawn_local(async move {
if let Err(err) = async_save_dialog(&file_name, &title, blob).await {
re_log::error!("File saving failed: {err}");
}
});
if let Some(image) = &image {
image_preview_ui(ctx, ui, ui_layout, query, entity_path, image);
}

// Native
#[cfg(not(target_arch = "wasm32"))]
{
let path = {
re_tracing::profile_scope!("file_dialog");
rfd::FileDialog::new()
.set_file_name(file_name)
.set_title(title)
.save_file()
};
if let Some(path) = path {
use re_viewer_context::SystemCommandSender as _;
ctx.command_sender
.send_system(re_viewer_context::SystemCommand::FileSaver(Box::new(
move || {
std::fs::write(&path, blob.as_slice())?;
Ok(path)
},
)));
}
}

Ok(())
}

#[cfg(target_arch = "wasm32")]
async fn async_save_dialog(file_name: &str, title: &str, data: Blob) -> anyhow::Result<()> {
use anyhow::Context as _;

let file_handle = rfd::AsyncFileDialog::new()
.set_file_name(file_name)
.set_title(title)
.save_file()
.await;
if !ui_layout.is_single_line() && ui_layout != UiLayout::Tooltip {
ui.horizontal(|ui| {
let text = if cfg!(target_arch = "wasm32") {
"Download blob…"
} else {
"Save blob…"
};
if ui.button(text).clicked() {
let mut file_name = entity_path
.last()
.map_or("blob", |name| name.unescaped_str())
.to_owned();

if let Some(file_extension) = media_type.as_ref().and_then(|mt| mt.file_extension())
{
file_name.push('.');
file_name.push_str(file_extension);
}

let Some(file_handle) = file_handle else {
return Ok(()); // aborted
};
ctx.save_file_dialog(file_name, "Save blob".to_owned(), blob.to_vec());
}

file_handle
.write(data.as_slice())
.await
.context("Failed to save")
#[cfg(not(target_arch = "wasm32"))]
if let Some(image) = image {
let image_stats = ctx
.cache
.entry(|c: &mut re_viewer_context::ImageStatsCache| c.entry(&image));
if let Ok(data_range) = re_viewer_context::gpu_bridge::image_data_range_heuristic(
&image_stats,
&image.format,
) {
crate::image::copy_image_button_ui(ui, &image, data_range);
}
}
});
}
}
Loading

0 comments on commit c25de89

Please sign in to comment.