diff --git a/docling_parse/pdf_parser.py b/docling_parse/pdf_parser.py index d6c1857ec..5ca5c0c00 100644 --- a/docling_parse/pdf_parser.py +++ b/docling_parse/pdf_parser.py @@ -1218,6 +1218,11 @@ def __init__( self._pages: Dict[tuple[int, int, int, int, int, bool], SegmentedPdfPage] = {} self._page_decoder: PdfPageDecoder | None = None self._default_image: PILImage.Image | None = None + # Last image rendered at a non-default scale for a cropped request: OCR + # crops several regions of a page at the same scale, and each crop would + # re-render the page. Full-page requests are not kept: callers such as + # Docling cache those already, and one per live page adds up quickly. + self._scaled_image: tuple[float, PILImage.Image] | None = None self.doc_key: str = raw_result.doc_key self.page_number: int = raw_result.page_number + 1 @@ -1448,7 +1453,9 @@ def _get_default_image(self) -> PILImage.Image: ) return self._default_image - def _render_image_at_scale(self, scale: float) -> PILImage.Image: + def _render_image_at_scale(self, scale: float, cache: bool) -> PILImage.Image: + if self._scaled_image is not None and self._scaled_image[0] == scale: + return self._scaled_image[1] page_decoder = self._require_page_decoder() render_config = self._rendering_config() render_config.scale = scale @@ -1459,7 +1466,10 @@ def _render_image_at_scale(self, scale: float) -> PILImage.Image: raise RuntimeError( f"Rendered image is empty for page {self.page_number} of {self.doc_key}" ) - return self._image_from_bytes(raw_bytes, image_shape) + image = self._image_from_bytes(raw_bytes, image_shape) + if cache: + self._scaled_image = (scale, image) + return image def _render_image_at_canvas_size( self, canvas_size: tuple[int, int] @@ -1519,7 +1529,7 @@ def get_image( ): image = self._get_default_image() else: - image = self._render_image_at_scale(scale) + image = self._render_image_at_scale(scale, cache=cropbox is not None) else: assert canvas_size is not None if canvas_size[0] <= 0 or canvas_size[1] <= 0: diff --git a/src/parse/config.h b/src/parse/config.h index 53109c4e9..4f3314995 100644 --- a/src/parse/config.h +++ b/src/parse/config.h @@ -74,6 +74,10 @@ namespace pdflib // magnify what it used to minify. Rendered output does change: the // discarded resolution levels are ones the rasteriser would have // resampled away, but not with the same filter the codec uses. + // + // A later render of the same instructions at a finer resolution decodes + // the reduced images again from their encoded bytes, so it matches a + // render whose decode was sized for it (bitmap_instruction::redecoded_for). double bitmap_target_pixels_per_unit = 0.0; // threading diff --git a/src/parse/page_items/render_instructions.h b/src/parse/page_items/render_instructions.h index d067ec62d..4933fc6a1 100644 --- a/src/parse/page_items/render_instructions.h +++ b/src/parse/page_items/render_instructions.h @@ -5,6 +5,8 @@ #include #include +#include +#include #include #include #include @@ -29,6 +31,15 @@ namespace pdflib CMYK_CONVENTION_PROCESS, }; + // The decoded samples of one image, as a bitmap_instruction carries them. + struct bitmap_samples + { + std::shared_ptr > data; + std::array shape = {0, 0, 0}; + pixel_format fmt = PIXEL_FORMAT_UNKNOWN; + cmyk_convention cmyk_conv = CMYK_CONVENTION_UNKNOWN; + }; + enum clip_rule { CLIP_RULE_NONE, CLIP_RULE_NONZERO, @@ -585,18 +596,58 @@ namespace pdflib void set_filters(std::vector filters) { filters_ = std::move(filters); } const std::vector& get_filters() const { return filters_; } + // Set when the samples were decoded at a reduced resolution, sized for a + // render at `pixels_per_unit` (decode_config::bitmap_target_pixels_per_unit). + // `redecode` decodes the image again for a finer target and returns empty + // samples when the stored ones already cover it. It holds the encoded + // bytes the page keeps anyway, never full-resolution pixels. + void set_redecoder(double pixels_per_unit, + std::function redecode) + { + decoded_for_pixels_per_unit_ = pixels_per_unit; + redecode_ = std::move(redecode); + } + + // A copy of this instruction with samples decoded for a render at + // `pixels_per_unit`, when the stored ones were reduced below what that + // render needs; otherwise nothing, and the stored samples are drawn. + std::optional redecoded_for(double pixels_per_unit) const + { + if(not redecode_ or pixels_per_unit <= decoded_for_pixels_per_unit_) + { + return std::nullopt; + } + + bitmap_samples samples = redecode_(pixels_per_unit); + if(not samples.data or samples.data->empty()) + { + return std::nullopt; + } + + bitmap_instruction sharper(*this); + sharper.data = std::move(samples.data); + sharper.shape = samples.shape; + sharper.fmt = samples.fmt; + sharper.cmyk_conv = samples.cmyk_conv; + sharper.redecode_ = nullptr; + return sharper; + } + private: + double decoded_for_pixels_per_unit_ = 0.0; + std::function redecode_; + blend_mode_name blend_mode_ = BLEND_MODE_NORMAL; std::vector filters_; const std::string xobject_key; - const std::shared_ptr > data; + std::shared_ptr > data; const std::shared_ptr > alpha_data; - const cmyk_convention cmyk_conv; - const std::array shape; - const pixel_format fmt; + cmyk_convention cmyk_conv; + std::array shape; + pixel_format fmt; const bool image_mask; const std::array rgb_filling; const double fill_alpha; diff --git a/src/parse/pdf_states/bitmap.h b/src/parse/pdf_states/bitmap.h index ff6233e2b..b9498119e 100644 --- a/src/parse/pdf_states/bitmap.h +++ b/src/parse/pdf_states/bitmap.h @@ -207,6 +207,12 @@ namespace pdflib clip_state_instruction clip_state, bitmap_source source); + // Decodes the image's samples, at 1/2^decode_reduction_shift of the + // stored resolution where the codec supports it. Depends on nothing but + // the image, so a bitmap_instruction can call it again at render time. + static bitmap_samples decode_samples(const page_item& image, + int decode_reduction_shift); + void populate_geometry(page_item& image, const clip_state_instruction& clip_state) const; @@ -576,13 +582,6 @@ namespace pdflib clip_state_instruction clip_state, bitmap_source source) { - std::shared_ptr> pixel_data; - std::array pixel_shape = {0, 0, 0}; - pixel_format fmt = PIXEL_FORMAT_UNKNOWN; - cmyk_convention cmyk_conv = CMYK_CONVENTION_UNKNOWN; - - int channels = 0; - // An oversampled image is decoded down to the resolution it is drawn at, // when the codec can do that natively. Three cases are held back: // @@ -610,6 +609,73 @@ namespace pdflib image.image_height, config.bitmap_target_pixels_per_unit) : 0; + + bitmap_samples samples = decode_samples(image, decode_reduction_shift); + + bitmap_instruction binstr(image.xobject_key, + std::move(samples.data), + image.soft_mask_data, + samples.cmyk_conv, + samples.shape, + samples.fmt, + image.image_mask, + image.rgb_filling_ops, + grph_state.get_fill_alpha(), + image.r_x0, image.r_y0, + image.r_x1, image.r_y1, + image.r_x2, image.r_y2, + image.r_x3, image.r_y3, + source, + std::move(clip_state)); + binstr.set_blend_mode(grph_state.get_blend_mode()); + binstr.set_filters(image.filters); + + // The reduced samples fit a render at the configured scale only. A later + // render at a finer scale (PageParseResult.get_image(scale=...)) decodes + // the image again from its encoded bytes, which the page keeps anyway. + // + // Only /DCTDecode and /JPXDecode honour the shift. Every other branch + // (Flate, raw, CCITT, JBIG2, or a stream QPDF already decoded) returns + // full-resolution samples, which a finer render would only decode again + // unchanged, so the re-decoder is kept for samples that really are reduced. + const auto& decoded_shape = binstr.get_shape(); + const bool samples_reduced = + binstr.has_data() + and (decoded_shape[0] < image.image_height + or decoded_shape[1] < image.image_width); + + if(decode_reduction_shift > 0 and samples_reduced) + { + binstr.set_redecoder( + config.bitmap_target_pixels_per_unit, + [image, decode_reduction_shift](double target_pixels_per_unit) -> bitmap_samples + { + const int shift = detail::codec_reduction_shift(image.x1 - image.x0, + image.y1 - image.y0, + image.image_width, + image.image_height, + target_pixels_per_unit); + if(shift >= decode_reduction_shift) + { + return {}; + } + return decode_samples(image, shift); + }); + } + + instructions.add_bitmap_instruction(std::move(binstr)); + } + + bitmap_samples pdf_state::decode_samples(const page_item& image, + int decode_reduction_shift) + { + std::shared_ptr> pixel_data; + std::array pixel_shape = {0, 0, 0}; + pixel_format fmt = PIXEL_FORMAT_UNKNOWN; + cmyk_convention cmyk_conv = CMYK_CONVENTION_UNKNOWN; + + int channels = 0; + auto has_default_adobe_cmyk_decode = [&](std::vector const& decode_array) -> bool { static constexpr double expected_decode[8] = { @@ -1623,25 +1689,7 @@ namespace pdflib } } - bitmap_instruction binstr(image.xobject_key, - std::move(pixel_data), - image.soft_mask_data, - cmyk_conv, - pixel_shape, - fmt, - image.image_mask, - image.rgb_filling_ops, - grph_state.get_fill_alpha(), - image.r_x0, image.r_y0, - image.r_x1, image.r_y1, - image.r_x2, image.r_y2, - image.r_x3, image.r_y3, - source, - std::move(clip_state)); - binstr.set_blend_mode(grph_state.get_blend_mode()); - binstr.set_filters(image.filters); - - instructions.add_bitmap_instruction(std::move(binstr)); + return {std::move(pixel_data), pixel_shape, fmt, cmyk_conv}; } } diff --git a/src/render/blend2d_renderer.h b/src/render/blend2d_renderer.h index eef9ddfb7..4a90063a7 100644 --- a/src/render/blend2d_renderer.h +++ b/src/render/blend2d_renderer.h @@ -3385,6 +3385,16 @@ namespace pdflib inline void renderer::render_bitmap(bitmap_instruction& instr) { + // Samples decoded down for a coarser render are decoded again when this + // render is finer, so a re-render at a larger scale is not upscaled. + const double pixels_per_unit = + config_.scale > 0.0f ? config_.scale : std::max(scale_x_, scale_y_); + if (auto sharper = instr.redecoded_for(pixels_per_unit)) + { + render_bitmap(*sharper); + return; + } + utils::scoped_timer phase_timer(timings_, pdf_timings::KEY_RENDER_BITMAP); LOG_S(INFO) << __FUNCTION__ << " for xobject_key=" << instr.get_key(); diff --git a/tests/test_unit_rerender_reduced_bitmaps.py b/tests/test_unit_rerender_reduced_bitmaps.py new file mode 100644 index 000000000..fd6ffcd8d --- /dev/null +++ b/tests/test_unit_rerender_reduced_bitmaps.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python +"""A re-render at a finer scale must not upscale bitmaps decoded for a coarser one. + +The render pipeline decodes an oversampled /DCTDecode image at 1/2, 1/4 or 1/8 +of its stored resolution when the configured render scale needs no more. +`get_image(scale=...)` later renders the same page again; when that scale is +finer, the image has to be decoded again, or the reduced samples are blown up +and the page comes out blurred -- as it did for OCR crops taken at scale 3 from +a parse rendered at scale 1. +""" + +from __future__ import annotations + +import io +import random + +from PIL import Image, ImageChops, ImageStat + +from tests.pdf_builder import render_page, simple_page_pdf, stream_object + +SOURCE_EXTENT = 512 + + +def noise_jpeg_page() -> bytes: + """512x512 grey noise drawn into 64x64 points of a 200x200 page. + + At scale 1 the image is 8x oversampled and decodes at 1/8 (64x64); at + scale 3 it decodes at 1/2 (256x256). Noise has detail at every frequency, + so an upscale of the 1/8 decode is easy to tell apart from the 1/2 decode. + """ + noise = random.Random(0).randbytes(SOURCE_EXTENT * SOURCE_EXTENT) + buffer = io.BytesIO() + Image.frombytes("L", (SOURCE_EXTENT, SOURCE_EXTENT), noise).save( + buffer, format="JPEG", quality=90 + ) + image = stream_object( + f"/Type /XObject /Subtype /Image /Width {SOURCE_EXTENT} " + f"/Height {SOURCE_EXTENT} /ColorSpace /DeviceGray /BitsPerComponent 8 " + "/Filter /DCTDecode", + buffer.getvalue(), + ) + return simple_page_pdf( + "q\n64 0 0 64 68 68 cm\n/Im0 Do\nQ\n", + resources="/XObject << /Im0 5 0 R >>", + extra_objects=[image], + ) + + +def mean_abs_difference(a: Image.Image, b: Image.Image) -> float: + assert a.size == b.size + return ImageStat.Stat(ImageChops.difference(a.convert("L"), b.convert("L"))).mean[0] + + +def test_rerender_at_finer_scale_matches_native_render(): + pdf = noise_jpeg_page() + + rerendered = render_page(pdf, scale=1.0).get_image(scale=3.0) + native = render_page(pdf, scale=3.0).get_image() + image_area = (204, 204, 396, 396) # the 64x64 pt image at scale 3 + + # Same samples through the same rasteriser, so the two match. Before the + # fix the re-render upscaled the 64x64 decode and differed by ~18 grey + # levels on average over the image. + assert ( + mean_abs_difference(rerendered.crop(image_area), native.crop(image_area)) < 0.5 + )