mirror of
https://github.com/esphome/esphome.git
synced 2026-10-22 22:06:12 +00:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2503dc49e3 | ||
|
|
2cd6ea7496 | ||
|
|
03a76a788f | ||
|
|
807217ead6 | ||
|
|
7eb279be69 | ||
|
|
6897c8a4a4 | ||
|
|
e70adbb060 | ||
|
|
5bdcf2b39d | ||
|
|
9dfa251d5b | ||
|
|
eb66aafc30 | ||
|
|
e24418d201 | ||
|
|
62f1a9b08f | ||
|
|
0bdd700c2e | ||
|
|
9545322be0 | ||
|
|
15afdf4dda | ||
|
|
74bdda341b | ||
|
|
6d90969f5f | ||
|
|
b1f83bc2dd | ||
|
|
660b7b11f6 | ||
|
|
67219ac41b |
@@ -22,6 +22,8 @@ DEPENDENCIES = ["http_request"]
|
||||
CODEOWNERS = ["@guillempages", "@clydebarrow"]
|
||||
|
||||
CONF_ON_DOWNLOAD_FINISHED = "on_download_finished"
|
||||
CONF_ON_DOWNLOAD_STARTED = "on_download_started"
|
||||
CONF_ON_DOWNLOAD_PROGRESS = "on_download_progress"
|
||||
CONF_UPDATE = "update"
|
||||
|
||||
online_image_ns = cg.esphome_ns.namespace("online_image")
|
||||
@@ -43,7 +45,9 @@ ONLINE_IMAGE_SCHEMA = (
|
||||
cv.Optional(CONF_REQUEST_HEADERS): cv.All(
|
||||
cv.Schema({cv.string: cv.templatable(cv.string)})
|
||||
),
|
||||
cv.Optional(CONF_ON_DOWNLOAD_STARTED): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_DOWNLOAD_FINISHED): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_DOWNLOAD_PROGRESS): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_ERROR): automation.validate_automation({}),
|
||||
}
|
||||
)
|
||||
@@ -96,9 +100,17 @@ automation.register_apply_action(
|
||||
|
||||
|
||||
_CALLBACK_AUTOMATIONS = (
|
||||
automation.CallbackAutomation(
|
||||
CONF_ON_DOWNLOAD_STARTED, "add_on_download_started_callback"
|
||||
),
|
||||
automation.CallbackAutomation(
|
||||
CONF_ON_DOWNLOAD_FINISHED, "add_on_finished_callback", [(bool, "cached")]
|
||||
),
|
||||
automation.CallbackAutomation(
|
||||
CONF_ON_DOWNLOAD_PROGRESS,
|
||||
"add_on_progress_callback",
|
||||
[(float, "progress"), (cg.size_t, "bytes_read")],
|
||||
),
|
||||
automation.CallbackAutomation(CONF_ON_ERROR, "add_on_error_callback"),
|
||||
)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "online_image");
|
||||
@@ -17,6 +18,8 @@ namespace esphome::online_image {
|
||||
|
||||
// Time one loop pass may spend reading, and decoding a JPEG as it downloads
|
||||
static constexpr uint32_t PASS_BUDGET_MS = 20;
|
||||
// Shortest time between two on_download_progress calls
|
||||
static constexpr uint32_t PROGRESS_INTERVAL_MS = 100;
|
||||
|
||||
OnlineImage::OnlineImage(const std::string &url, int width, int height, runtime_image::ImageFormat format,
|
||||
image::ImageType type, image::Transparency transparency, image::Image *placeholder,
|
||||
@@ -159,6 +162,8 @@ void OnlineImage::update() {
|
||||
ESP_LOGI(TAG, "Downloading image (Size: %zu)", total_size);
|
||||
this->start_time_ = millis();
|
||||
this->last_data_time_ = this->start_time_;
|
||||
this->download_started_callback_.call();
|
||||
this->report_progress_(true);
|
||||
this->enable_loop();
|
||||
}
|
||||
|
||||
@@ -206,6 +211,7 @@ void OnlineImage::loop() {
|
||||
|
||||
if (received > 0) {
|
||||
this->last_data_time_ = now;
|
||||
this->report_progress_(false);
|
||||
|
||||
// Feed data to decoder
|
||||
auto consumed = this->feed_data(this->download_buffer_.data(), this->download_buffer_.unread());
|
||||
@@ -297,6 +303,7 @@ void OnlineImage::loop_stream_() {
|
||||
break;
|
||||
this->last_data_time_ = now;
|
||||
this->download_buffer_.write(len);
|
||||
this->report_progress_(false);
|
||||
} while (this->downloader_->get_bytes_read() < total && this->download_buffer_.free_capacity() != 0 &&
|
||||
millis() - now < PASS_BUDGET_MS);
|
||||
if (len < 0) {
|
||||
@@ -363,6 +370,18 @@ void OnlineImage::loop_stream_() {
|
||||
} while (millis() - now < PASS_BUDGET_MS);
|
||||
}
|
||||
|
||||
void OnlineImage::report_progress_(bool force) {
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
const size_t bytes = this->downloader_->get_bytes_read();
|
||||
const size_t total = this->downloader_->content_length;
|
||||
// The last bytes are always reported, so a progress bar reaches 100%
|
||||
if (!force && now - this->last_progress_time_ < PROGRESS_INTERVAL_MS && (total == 0 || bytes < total))
|
||||
return;
|
||||
this->last_progress_time_ = now;
|
||||
const float progress = total > 0 ? std::min(100.0f, bytes * 100.0f / total) : NAN;
|
||||
this->download_progress_callback_.call(progress, bytes);
|
||||
}
|
||||
|
||||
void OnlineImage::fail_download_() {
|
||||
this->end_connection_();
|
||||
this->download_error_callback_.call();
|
||||
|
||||
@@ -74,9 +74,17 @@ class OnlineImage final : public PollingComponent,
|
||||
*/
|
||||
void release();
|
||||
|
||||
/// Called when the server has accepted the request and the image starts to download
|
||||
template<typename F> void add_on_download_started_callback(F &&callback) {
|
||||
this->download_started_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
template<typename F> void add_on_finished_callback(F &&callback) {
|
||||
this->download_finished_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
/// Called with the percentage downloaded (NAN when the server did not send the size) and the bytes read so far
|
||||
template<typename F> void add_on_progress_callback(F &&callback) {
|
||||
this->download_progress_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
template<typename F> void add_on_error_callback(F &&callback) {
|
||||
this->download_error_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
@@ -88,9 +96,13 @@ class OnlineImage final : public PollingComponent,
|
||||
/// loop() for a JPEG that is decoded as it downloads
|
||||
void loop_stream_();
|
||||
void fail_download_();
|
||||
/// Reports download progress, at most every PROGRESS_INTERVAL_MS unless force is set
|
||||
void report_progress_(bool force);
|
||||
|
||||
CallbackManager<void(bool)> download_finished_callback_{};
|
||||
CallbackManager<void()> download_error_callback_{};
|
||||
LazyCallbackManager<void()> download_started_callback_{};
|
||||
LazyCallbackManager<void(float, size_t)> download_progress_callback_{};
|
||||
|
||||
std::shared_ptr<http_request::HttpContainer> downloader_{nullptr};
|
||||
DownloadBuffer download_buffer_;
|
||||
@@ -117,6 +129,7 @@ class OnlineImage final : public PollingComponent,
|
||||
|
||||
uint32_t start_time_{0};
|
||||
uint32_t last_data_time_{0};
|
||||
uint32_t last_progress_time_{0};
|
||||
bool streaming_{false}; // The decoder reads the download itself, as it arrives
|
||||
bool stream_started_{false}; // The decoder has read the headers
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
#include "runtime_image.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace esphome::runtime_image {
|
||||
|
||||
@@ -11,19 +10,48 @@ ESPHOME_LOG_TAG(TAG, "image_decoder");
|
||||
bool ImageDecoder::set_size(int width, int height) {
|
||||
bool success = this->image_->resize(width, height) > 0;
|
||||
this->size_valid_ = success;
|
||||
this->x_scale_ = static_cast<double>(this->image_->get_buffer_width()) / width;
|
||||
this->y_scale_ = static_cast<double>(this->image_->get_buffer_height()) / height;
|
||||
this->draw_width_ = width;
|
||||
this->draw_height_ = height;
|
||||
return success;
|
||||
}
|
||||
|
||||
int ImageDecoder::scaled_x_(int x) const {
|
||||
if (this->draw_width_ <= 0)
|
||||
return 0;
|
||||
const uint32_t buffer_width = this->image_->get_buffer_width();
|
||||
return static_cast<int>((static_cast<uint32_t>(x) * buffer_width + this->draw_width_ - 1) / this->draw_width_);
|
||||
}
|
||||
|
||||
int ImageDecoder::scaled_y_(int y) const {
|
||||
if (this->draw_height_ <= 0)
|
||||
return 0;
|
||||
const uint32_t buffer_height = this->image_->get_buffer_height();
|
||||
return static_cast<int>((static_cast<uint32_t>(y) * buffer_height + this->draw_height_ - 1) / this->draw_height_);
|
||||
}
|
||||
|
||||
void ImageDecoder::draw(int x, int y, int w, int h, const Color &color) {
|
||||
if (!this->size_valid_) {
|
||||
return;
|
||||
}
|
||||
auto width = std::min(this->image_->get_buffer_width(), static_cast<int>(std::ceil((x + w) * this->x_scale_)));
|
||||
auto height = std::min(this->image_->get_buffer_height(), static_cast<int>(std::ceil((y + h) * this->y_scale_)));
|
||||
for (int i = x * this->x_scale_; i < width; i++) {
|
||||
for (int j = y * this->y_scale_; j < height; j++) {
|
||||
// A source pixel paints the target pixels from ceil(start * scale) up to ceil(end * scale). The spans of neighbouring
|
||||
// pixels meet without overlapping, so when an image is shrunk each target pixel is painted once, by the first
|
||||
// source pixel that reaches it, rather than by every pixel that lands on it. Integer maths: the S3 has no double FPU.
|
||||
const int buffer_width = this->image_->get_buffer_width();
|
||||
const int buffer_height = this->image_->get_buffer_height();
|
||||
int x1 = x;
|
||||
int y1 = y;
|
||||
int x2 = x + w;
|
||||
int y2 = y + h;
|
||||
if (buffer_width != this->draw_width_ || buffer_height != this->draw_height_) {
|
||||
x1 = this->scaled_x_(x);
|
||||
y1 = this->scaled_y_(y);
|
||||
x2 = this->scaled_x_(x + w);
|
||||
y2 = this->scaled_y_(y + h);
|
||||
}
|
||||
x2 = std::min(buffer_width, x2);
|
||||
y2 = std::min(buffer_height, y2);
|
||||
for (int i = x1; i < x2; i++) {
|
||||
for (int j = y1; j < y2; j++) {
|
||||
this->image_->draw_pixel(i, j, color);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,8 +83,8 @@ class ImageDecoder {
|
||||
this->expected_size_ = 0;
|
||||
this->decoded_bytes_ = 0;
|
||||
this->size_valid_ = true;
|
||||
this->x_scale_ = 1.0;
|
||||
this->y_scale_ = 1.0;
|
||||
this->draw_width_ = 0;
|
||||
this->draw_height_ = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,8 +184,13 @@ class ImageDecoder {
|
||||
uint8_t *image_buffer_() const;
|
||||
bool image_is_big_endian_() const;
|
||||
|
||||
double x_scale_ = 1.0;
|
||||
double y_scale_ = 1.0;
|
||||
/// The first buffer column, or row, that source column x, or row y, paints: ceil(x * buffer size / draw size)
|
||||
int scaled_x_(int x) const;
|
||||
int scaled_y_(int y) const;
|
||||
|
||||
// Size of the image as the decoder draws it, which draw() spreads over the buffer
|
||||
int draw_width_ = 0;
|
||||
int draw_height_ = 0;
|
||||
RuntimeImage *image_;
|
||||
size_t expected_size_ = 0; // Expected data size (0 if unknown)
|
||||
size_t decoded_bytes_ = 0; // Bytes processed so far
|
||||
|
||||
@@ -31,8 +31,8 @@ bool JpegDecoder::prepare_rgb565_scaling_(int width, int height) {
|
||||
const uint32_t target_width = this->image_->get_buffer_width();
|
||||
const uint32_t target_height = this->image_->get_buffer_height();
|
||||
// Boundary n is the first target pixel source pixel n covers, rounded up. When shrinking, the source
|
||||
// pixel whose span lands on a target pixel paints it, so an 8 to 4 scale keeps columns 0, 2, 4 and 6;
|
||||
// the generic draw() path rounds down and keeps the odd ones instead
|
||||
// pixel whose span lands on a target pixel paints it, so an 8 to 4 scale keeps columns 0, 2, 4 and 6,
|
||||
// as the generic draw() path does
|
||||
for (uint32_t x = 0; x <= static_cast<uint32_t>(width); x++) {
|
||||
this->x_boundaries_.push_back(static_cast<uint16_t>((x * target_width + width - 1) / width));
|
||||
}
|
||||
@@ -138,7 +138,7 @@ int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) {
|
||||
const int err = this->start_();
|
||||
if (err != 0)
|
||||
return err;
|
||||
if (!this->jpeg_.decode(0, 0, 0))
|
||||
if (!this->jpeg_.decode(0, 0, this->scale_option_()))
|
||||
return this->fail_();
|
||||
this->decoded_bytes_ = size;
|
||||
this->jpeg_.close();
|
||||
@@ -165,10 +165,28 @@ int JpegDecoder::start_() {
|
||||
} else {
|
||||
this->jpeg_.setPixelType(RGB8888);
|
||||
}
|
||||
if (!this->set_size(this->jpeg_.getWidth(), this->jpeg_.getHeight())) {
|
||||
const int width = this->jpeg_.getWidth();
|
||||
const int height = this->jpeg_.getHeight();
|
||||
if (!this->set_size(width, height)) {
|
||||
return DECODE_ERROR_OUT_OF_MEMORY;
|
||||
}
|
||||
if (this->direct_rgb565_ && !this->prepare_rgb565_scaling_(this->jpeg_.getWidth(), this->jpeg_.getHeight())) {
|
||||
// JPEGDEC can decode at 1/2, 1/4 or 1/8 size for much less work. Use the smallest size that still has at least as
|
||||
// many pixels as the buffer, so a large image shown small is not decoded in full only to be thrown away.
|
||||
const int buffer_width = this->image_->get_buffer_width();
|
||||
const int buffer_height = this->image_->get_buffer_height();
|
||||
this->scale_ = 1;
|
||||
while (this->scale_ < JPEG_SCALE_EIGHTH && width / (this->scale_ * 2) >= buffer_width &&
|
||||
height / (this->scale_ * 2) >= buffer_height) {
|
||||
this->scale_ *= 2;
|
||||
}
|
||||
const int decoded_width = (width + this->scale_ - 1) / this->scale_;
|
||||
const int decoded_height = (height + this->scale_ - 1) / this->scale_;
|
||||
this->draw_width_ = decoded_width;
|
||||
this->draw_height_ = decoded_height;
|
||||
if (this->scale_ != 1) {
|
||||
ESP_LOGD(TAG, "Decoding at 1/%d size, %d x %d", this->scale_, decoded_width, decoded_height);
|
||||
}
|
||||
if (this->direct_rgb565_ && !this->prepare_rgb565_scaling_(decoded_width, decoded_height)) {
|
||||
return DECODE_ERROR_OUT_OF_MEMORY;
|
||||
}
|
||||
return 0;
|
||||
@@ -252,7 +270,7 @@ int JpegDecoder::begin_source(DecodeSource *source) {
|
||||
this->source_ = nullptr;
|
||||
return err;
|
||||
}
|
||||
if (!this->jpeg_.decodeBegin(0, 0, 0))
|
||||
if (!this->jpeg_.decodeBegin(0, 0, this->scale_option_()))
|
||||
return this->fail_();
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -31,12 +31,16 @@ class JpegDecoder : public ImageDecoder {
|
||||
int HOT decode_step() override;
|
||||
|
||||
bool uses_direct_rgb565() const { return this->direct_rgb565_; }
|
||||
/// JPEGDEC decodes the image at 1/get_scale() of its size: 1, 2, 4 or 8
|
||||
int get_scale() const { return this->scale_; }
|
||||
void draw_rgb565(int x, int y, int w, int h, const uint8_t *pixels);
|
||||
|
||||
protected:
|
||||
bool prepare_rgb565_scaling_(int width, int height);
|
||||
/// Set up an opened image for decoding. Returns 0, or a DecodeError.
|
||||
int start_();
|
||||
/// The JPEGDEC option for scale_: JPEG_SCALE_HALF, _QUARTER and _EIGHTH are the divisors themselves
|
||||
int scale_option_() const { return this->scale_ == 1 ? 0 : this->scale_; }
|
||||
/// The DecodeError for a failed read of the source or JPEGDEC's last error, closing the image
|
||||
int fail_();
|
||||
|
||||
@@ -49,6 +53,7 @@ class JpegDecoder : public ImageDecoder {
|
||||
FixedVector<uint16_t> y_boundaries_{};
|
||||
int source_width_{0};
|
||||
int source_height_{0};
|
||||
int scale_{1}; // JPEGDEC decodes at 1/scale_ of the image size
|
||||
DecodeSource *source_{nullptr}; // Set while a begin_source() decode is in progress
|
||||
bool direct_rgb565_{false};
|
||||
bool source_failed_{false};
|
||||
|
||||
@@ -19,6 +19,11 @@ image:
|
||||
} else {
|
||||
ESP_LOGD("online_image", "Cache miss: fresh download");
|
||||
}
|
||||
on_download_started:
|
||||
- logger.log: Download started
|
||||
on_download_progress:
|
||||
lambda: |-
|
||||
ESP_LOGD("online_image", "Downloaded %.0f%% (%zu bytes)", progress, bytes_read);
|
||||
- platform: online_image
|
||||
id: online_binary_transparent_image
|
||||
url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
@@ -619,6 +620,81 @@ TEST_F(OnlineImageDownloadEnd, JpegProgressiveReportsError) {
|
||||
EXPECT_EQ(this->finished_, 0);
|
||||
}
|
||||
|
||||
// on_download_started fires once the server has accepted the request, before the first progress report, and not
|
||||
// for a response that has no image to download
|
||||
TEST_F(OnlineImageDownloadEnd, StartedFiresOnlyWhenTheDownloadBegins) {
|
||||
auto image = this->make_image_(runtime_image::JPEG, 1024);
|
||||
std::vector<std::string> events;
|
||||
image->add_on_download_started_callback([&events]() { events.emplace_back("started"); });
|
||||
image->add_on_progress_callback([&events](float, size_t) { events.emplace_back("progress"); });
|
||||
this->start_(*image,
|
||||
std::make_shared<FakeContainer>(std::vector<uint8_t>(JPEG_48X40, JPEG_48X40 + sizeof(JPEG_48X40)),
|
||||
sizeof(JPEG_48X40), false));
|
||||
ASSERT_GE(events.size(), 2u);
|
||||
EXPECT_EQ(events[0], "started");
|
||||
EXPECT_EQ(events[1], "progress");
|
||||
for (int i = 0; i < 100 && this->finished_ + this->errors_ == 0; i++)
|
||||
image->loop();
|
||||
EXPECT_EQ(std::count(events.begin(), events.end(), std::string("started")), 1);
|
||||
|
||||
for (int status : {304, 404}) {
|
||||
events.clear();
|
||||
auto container = std::make_shared<FakeContainer>(std::vector<uint8_t>{}, 0, false);
|
||||
container->status_code = status;
|
||||
this->http_.next = container;
|
||||
image->update();
|
||||
EXPECT_TRUE(events.empty()) << "HTTP " << status;
|
||||
}
|
||||
}
|
||||
|
||||
// Progress starts at 0, is reported at most every 100 ms, and always reaches 100%
|
||||
TEST_F(OnlineImageDownloadEnd, ProgressIsReportedAsTheImageDownloads) {
|
||||
auto image = this->make_image_(runtime_image::JPEG, 1024);
|
||||
std::vector<float> reports;
|
||||
image->add_on_progress_callback([&reports](float progress, size_t) { reports.push_back(progress); });
|
||||
auto container =
|
||||
std::make_shared<FakeContainer>(std::vector<uint8_t>(JPEG_48X40, JPEG_48X40 + sizeof(JPEG_48X40)), 100, false);
|
||||
uint32_t now = 1000;
|
||||
set_loop_time(now);
|
||||
this->start_(*image, container);
|
||||
ASSERT_EQ(reports.size(), 1u);
|
||||
EXPECT_EQ(reports[0], 0.0f);
|
||||
|
||||
int passes = 0;
|
||||
for (; passes < 1000 && this->finished_ + this->errors_ == 0; passes++) {
|
||||
now += 50;
|
||||
set_loop_time(now);
|
||||
container->deliver(100);
|
||||
image->loop();
|
||||
}
|
||||
|
||||
ASSERT_EQ(this->finished_, 1);
|
||||
EXPECT_EQ(reports.back(), 100.0f);
|
||||
EXPECT_TRUE(std::is_sorted(reports.begin(), reports.end()));
|
||||
// A report every 100 ms of a 50 ms loop, plus the start and the end
|
||||
EXPECT_LE(reports.size(), static_cast<size_t>(passes / 2 + 2));
|
||||
EXPECT_GT(reports.size(), 3u);
|
||||
}
|
||||
|
||||
// Without a Content-Length the percentage is unknown, but the bytes read are still reported
|
||||
TEST_F(OnlineImageDownloadEnd, ProgressOfAChunkedDownloadHasNoPercentage) {
|
||||
auto image = this->make_image_(runtime_image::PNG);
|
||||
std::vector<std::pair<float, size_t>> reports;
|
||||
image->add_on_progress_callback(
|
||||
[&reports](float progress, size_t bytes_read) { reports.emplace_back(progress, bytes_read); });
|
||||
auto container =
|
||||
std::make_shared<FakeContainer>(std::vector<uint8_t>(PNG_1X1.begin(), PNG_1X1.end()), PNG_1X1.size(), true);
|
||||
set_loop_time(1000);
|
||||
this->start_(*image, container);
|
||||
set_loop_time(2000);
|
||||
for (int i = 0; i < 10 && this->finished_ + this->errors_ == 0; i++)
|
||||
image->loop();
|
||||
|
||||
ASSERT_GE(reports.size(), 2u);
|
||||
EXPECT_TRUE(std::isnan(reports.back().first));
|
||||
EXPECT_EQ(reports.back().second, PNG_1X1.size());
|
||||
}
|
||||
|
||||
// Scan data that cannot be decoded fails the download part way through
|
||||
TEST_F(OnlineImageDownloadEnd, JpegDecodeErrorFailsTheDownload) {
|
||||
auto image = this->make_image_(runtime_image::JPEG, 1024);
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstring>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "esphome/components/runtime_image/image_decoder.h"
|
||||
#include "esphome/components/runtime_image/jpeg_decoder.h"
|
||||
#include "esphome/components/runtime_image/runtime_image.h"
|
||||
|
||||
namespace esphome::runtime_image::testing {
|
||||
@@ -362,6 +364,23 @@ static std::vector<uint8_t> pixel_bytes(TestableRuntimeImage &img) {
|
||||
return std::vector<uint8_t>(start, start + img.get_width_stride() * img.get_height());
|
||||
}
|
||||
|
||||
// Shrinking a PNG keeps the first source pixel of each span, as the JPEG path does: the even rows and columns
|
||||
TEST(RuntimeImageDecoder, ShrunkPngKeepsTheFirstPixelOfEachSpan) {
|
||||
TestableRuntimeImage img(PNG, image::IMAGE_TYPE_RGB, false, 2, 2);
|
||||
ASSERT_TRUE(decode_all(img, PNG_RGB, sizeof(PNG_RGB), PNG));
|
||||
ASSERT_EQ(img.get_width(), 2);
|
||||
ASSERT_EQ(img.get_height(), 2);
|
||||
for (int row = 0; row < 2; row++) {
|
||||
for (int col = 0; col < 2; col++) {
|
||||
const Color pixel = img.get_pixel(col, row);
|
||||
const uint8_t *expected = PNG_RGB_EXPECTED[row * 2][col * 2];
|
||||
EXPECT_EQ(pixel.r, expected[0]) << col << "," << row;
|
||||
EXPECT_EQ(pixel.g, expected[1]) << col << "," << row;
|
||||
EXPECT_EQ(pixel.b, expected[2]) << col << "," << row;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST(RuntimeImageDecoder, JpegDecoderStaysWarmAcrossDecodes) {
|
||||
TestableRuntimeImage img(JPEG);
|
||||
|
||||
@@ -377,6 +396,12 @@ TEST(RuntimeImageDecoder, JpegDecoderStaysWarmAcrossDecodes) {
|
||||
EXPECT_EQ(pixel_bytes(img), first_pixels) << "reused decoder must reproduce identical pixels";
|
||||
}
|
||||
|
||||
// The RGB565 word at (x, y) of a decoded image, in its own byte order
|
||||
static uint16_t rgb565_word(const TestableRuntimeImage &img, int x, int y) {
|
||||
const uint8_t *p = img.get_data_start() + (y * img.get_width() + x) * 2;
|
||||
return p[0] | (p[1] << 8);
|
||||
}
|
||||
|
||||
// JPEGDEC's colour conversion differs by one step between its SIMD and scalar builds, so each
|
||||
// channel is checked within one step; the byte order is still exact
|
||||
static void expect_rgb565_near(uint16_t actual, uint16_t expected, const char *order, size_t pixel) {
|
||||
@@ -386,16 +411,12 @@ static void expect_rgb565_near(uint16_t actual, uint16_t expected, const char *o
|
||||
}
|
||||
|
||||
TEST(RuntimeImageDecoder, JpegDecodesDirectlyToScaledRgb565WithBothByteOrders) {
|
||||
// Halving the 8x8 gradient keeps its even rows and columns. The words are JPEGDEC's output for those
|
||||
// pixels, red in the top five bits: a decode of the fixture with PIL gives (51, 7, 30) for source
|
||||
// pixel (2, 0), which is word 0x3803 here.
|
||||
static const uint16_t EXPECTED[4 * 4] = {
|
||||
0x0000, 0x3803, 0x8008, 0xB80B, 0x0203, 0x3A07, 0x81EC, 0xBA0F,
|
||||
0x03E7, 0x3C0B, 0x83F0, 0xBC13, 0x05EC, 0x3E0F, 0x85F4, 0xBE17,
|
||||
};
|
||||
// Halving the 8x8 gradient makes JPEGDEC decode it at half size, which averages each 2x2 block of pixels
|
||||
TestableRuntimeImage full(JPEG, image::IMAGE_TYPE_RGB565, false, 8, 8);
|
||||
TestableRuntimeImage rgb565_little_endian(JPEG, image::IMAGE_TYPE_RGB565, false, 4, 4);
|
||||
TestableRuntimeImage rgb565_big_endian(JPEG, image::IMAGE_TYPE_RGB565, true, 4, 4);
|
||||
|
||||
ASSERT_TRUE(decode_all(full, JPEG_GRADIENT, sizeof(JPEG_GRADIENT)));
|
||||
ASSERT_TRUE(decode_all(rgb565_little_endian, JPEG_GRADIENT, sizeof(JPEG_GRADIENT)));
|
||||
ASSERT_TRUE(decode_all(rgb565_big_endian, JPEG_GRADIENT, sizeof(JPEG_GRADIENT)));
|
||||
ASSERT_EQ(rgb565_little_endian.get_width(), 4);
|
||||
@@ -405,9 +426,53 @@ TEST(RuntimeImageDecoder, JpegDecodesDirectlyToScaledRgb565WithBothByteOrders) {
|
||||
|
||||
const uint8_t *little = rgb565_little_endian.get_data_start();
|
||||
const uint8_t *big = rgb565_big_endian.get_data_start();
|
||||
for (size_t i = 0; i < 4 * 4; i++) {
|
||||
expect_rgb565_near(little[i * 2] | (little[i * 2 + 1] << 8), EXPECTED[i], "little-endian", i);
|
||||
expect_rgb565_near((big[i * 2] << 8) | big[i * 2 + 1], EXPECTED[i], "big-endian", i);
|
||||
for (int row = 0; row < 4; row++) {
|
||||
for (int col = 0; col < 4; col++) {
|
||||
int red = 0;
|
||||
int green = 0;
|
||||
int blue = 0;
|
||||
for (int dy = 0; dy < 2; dy++) {
|
||||
for (int dx = 0; dx < 2; dx++) {
|
||||
const uint16_t word = rgb565_word(full, col * 2 + dx, row * 2 + dy);
|
||||
red += word >> 11;
|
||||
green += (word >> 5) & 0x3F;
|
||||
blue += word & 0x1F;
|
||||
}
|
||||
}
|
||||
const uint16_t expected = ((red + 2) / 4) << 11 | ((green + 2) / 4) << 5 | (blue + 2) / 4;
|
||||
const size_t i = row * 4 + col;
|
||||
expect_rgb565_near(little[i * 2] | (little[i * 2 + 1] << 8), expected, "little-endian", i);
|
||||
expect_rgb565_near((big[i * 2] << 8) | big[i * 2 + 1], expected, "big-endian", i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The generic draw() path gets JPEGDEC's half size RGB8888 output, so it too averages each 2x2 block of pixels
|
||||
TEST(RuntimeImageDecoder, JpegDecodesToScaledRgb) {
|
||||
TestableRuntimeImage full(JPEG, image::IMAGE_TYPE_RGB, false, 8, 8);
|
||||
TestableRuntimeImage half(JPEG, image::IMAGE_TYPE_RGB, false, 4, 4);
|
||||
ASSERT_TRUE(decode_all(full, JPEG_GRADIENT, sizeof(JPEG_GRADIENT)));
|
||||
ASSERT_TRUE(decode_all(half, JPEG_GRADIENT, sizeof(JPEG_GRADIENT)));
|
||||
ASSERT_EQ(static_cast<JpegDecoder *>(half.decoder())->get_scale(), 2);
|
||||
|
||||
for (int row = 0; row < 4; row++) {
|
||||
for (int col = 0; col < 4; col++) {
|
||||
int red = 0;
|
||||
int green = 0;
|
||||
int blue = 0;
|
||||
for (int dy = 0; dy < 2; dy++) {
|
||||
for (int dx = 0; dx < 2; dx++) {
|
||||
const Color pixel = full.get_pixel(col * 2 + dx, row * 2 + dy);
|
||||
red += pixel.r;
|
||||
green += pixel.g;
|
||||
blue += pixel.b;
|
||||
}
|
||||
}
|
||||
const Color pixel = half.get_pixel(col, row);
|
||||
EXPECT_NEAR(pixel.r, (red + 2) / 4, 8) << "pixel " << col << "," << row << " red";
|
||||
EXPECT_NEAR(pixel.g, (green + 2) / 4, 8) << "pixel " << col << "," << row << " green";
|
||||
EXPECT_NEAR(pixel.b, (blue + 2) / 4, 8) << "pixel " << col << "," << row << " blue";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,10 +480,15 @@ TEST(RuntimeImageDecoder, JpegDecodesDirectlyToScaledRgb565WithBothByteOrders) {
|
||||
// prepare_rgb565_scaling_ rounds: source n covers targets ceil(n * target / source) up to the next start
|
||||
static uint16_t scaled_boundary(int n, int source, int target) { return (n * target + source - 1) / source; }
|
||||
|
||||
// The RGB565 word at (x, y) of a decoded image, in its own byte order
|
||||
static uint16_t rgb565_word(const TestableRuntimeImage &img, int x, int y) {
|
||||
const uint8_t *p = img.get_data_start() + (y * img.get_width() + x) * 2;
|
||||
return p[0] | (p[1] << 8);
|
||||
// JPEGDEC decodes at the smallest of full, 1/2, 1/4 and 1/8 size that still covers the buffer
|
||||
TEST(RuntimeImageDecoder, JpegDecodesAtTheSmallestSizeThatCoversTheBuffer) {
|
||||
for (auto [size, scale] :
|
||||
{std::pair{8, 1}, std::pair{5, 1}, std::pair{4, 2}, std::pair{3, 2}, std::pair{2, 4}, std::pair{1, 8}}) {
|
||||
TestableRuntimeImage img(JPEG, image::IMAGE_TYPE_RGB565, false, size, size);
|
||||
ASSERT_TRUE(decode_all(img, JPEG_GRADIENT, sizeof(JPEG_GRADIENT)));
|
||||
EXPECT_EQ(img.get_width(), size);
|
||||
EXPECT_EQ(static_cast<JpegDecoder *>(img.decoder())->get_scale(), scale) << size << "x" << size;
|
||||
}
|
||||
}
|
||||
|
||||
// Every scaled direct decode must equal the unscaled one sampled through the boundary table,
|
||||
@@ -427,7 +497,8 @@ TEST(RuntimeImageDecoder, JpegDirectRgb565ScalesBySourcePixelSpans) {
|
||||
TestableRuntimeImage full(JPEG, image::IMAGE_TYPE_RGB565, false, 8, 8);
|
||||
ASSERT_TRUE(decode_all(full, JPEG_GRADIENT, sizeof(JPEG_GRADIENT)));
|
||||
|
||||
for (int size : {4, 6, 16}) {
|
||||
// Sizes JPEGDEC cannot reach by scaling, so the full size decode is spread over the buffer
|
||||
for (int size : {5, 6, 16}) {
|
||||
TestableRuntimeImage scaled(JPEG, image::IMAGE_TYPE_RGB565, false, size, size);
|
||||
ASSERT_TRUE(decode_all(scaled, JPEG_GRADIENT, sizeof(JPEG_GRADIENT)));
|
||||
ASSERT_EQ(scaled.get_width(), size);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "esphome/components/runtime_image/image_decoder.h"
|
||||
#include "esphome/components/runtime_image/jpeg_decoder.h"
|
||||
#include "esphome/components/runtime_image/runtime_image.h"
|
||||
|
||||
#ifdef USE_RUNTIME_IMAGE_JPEG
|
||||
@@ -178,6 +179,7 @@ class SourceImage : public RuntimeImage {
|
||||
explicit SourceImage(image::ImageType type = image::IMAGE_TYPE_RGB, int width = 0, int height = 0)
|
||||
: RuntimeImage(JPEG, type, image::TRANSPARENCY_OPAQUE, nullptr, false, width, height) {}
|
||||
size_t decoded_bytes() const { return this->decoded_bytes_; }
|
||||
int scale() const { return static_cast<const JpegDecoder *>(this->decoder_.get())->get_scale(); }
|
||||
};
|
||||
|
||||
/// The file in memory; fails reads once fail_at bytes have been read
|
||||
@@ -291,6 +293,23 @@ TEST(JpegSourceDecode, SteppedDirectRgb565MatchesFedDecode) {
|
||||
EXPECT_EQ(pixels(stepped), pixels(fed));
|
||||
}
|
||||
|
||||
// decodeBegin() is given the same reduced scale as decode(), and decodeRows() keeps it for every step
|
||||
TEST(JpegSourceDecode, SteppedScaledDecodeMatchesFedDecode) {
|
||||
for (auto type : {image::IMAGE_TYPE_RGB565, image::IMAGE_TYPE_RGB}) {
|
||||
// 24x20 is half of 48x40, so JPEGDEC decodes at 1/2 size
|
||||
SourceImage fed(type, 24, 20);
|
||||
ASSERT_TRUE(decode_fed(fed));
|
||||
ASSERT_EQ(fed.scale(), 2);
|
||||
|
||||
SourceImage stepped(type, 24, 20);
|
||||
MemorySource source(JPEG_ROWS, sizeof(JPEG_ROWS));
|
||||
int steps;
|
||||
ASSERT_EQ(decode_stepped(stepped, source, steps), DECODE_STEP_DONE);
|
||||
ASSERT_EQ(stepped.scale(), 2);
|
||||
EXPECT_EQ(pixels(stepped), pixels(fed)) << "image type " << type;
|
||||
}
|
||||
}
|
||||
|
||||
TEST(JpegSourceDecode, WaitsForDataThatHasNotArrived) {
|
||||
SourceImage fed;
|
||||
ASSERT_TRUE(decode_fed(fed));
|
||||
|
||||
Reference in New Issue
Block a user