Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions docling_parse/pdf_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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]
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions src/parse/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 55 additions & 4 deletions src/parse/page_items/render_instructions.h
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

#include <algorithm>
#include <cstdint>
#include <functional>
#include <optional>
#include <string>
#include <vector>
#include <array>
Expand All @@ -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<std::vector<uint8_t> > data;
std::array<int, 3> 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,
Expand Down Expand Up @@ -585,18 +596,58 @@ namespace pdflib
void set_filters(std::vector<std::string> filters) { filters_ = std::move(filters); }
const std::vector<std::string>& 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<bitmap_samples(double)> 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<bitmap_instruction> 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<bitmap_samples(double)> redecode_;

blend_mode_name blend_mode_ = BLEND_MODE_NORMAL;
std::vector<std::string> filters_;

const std::string xobject_key;

const std::shared_ptr<std::vector<uint8_t> > data;
std::shared_ptr<std::vector<uint8_t> > data;
const std::shared_ptr<std::vector<uint8_t> > alpha_data;
const cmyk_convention cmyk_conv;
const std::array<int, 3> shape;
const pixel_format fmt;
cmyk_convention cmyk_conv;
std::array<int, 3> shape;
pixel_format fmt;
const bool image_mask;
const std::array<int, 3> rgb_filling;
const double fill_alpha;
Expand Down
100 changes: 74 additions & 26 deletions src/parse/pdf_states/bitmap.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<PAGE_IMAGE>& image,
int decode_reduction_shift);

void populate_geometry(page_item<PAGE_IMAGE>& image,
const clip_state_instruction& clip_state) const;

Expand Down Expand Up @@ -576,13 +582,6 @@ namespace pdflib
clip_state_instruction clip_state,
bitmap_source source)
{
std::shared_ptr<std::vector<uint8_t>> pixel_data;
std::array<int, 3> 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:
//
Expand Down Expand Up @@ -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<BITMAP>::decode_samples(const page_item<PAGE_IMAGE>& image,
int decode_reduction_shift)
{
std::shared_ptr<std::vector<uint8_t>> pixel_data;
std::array<int, 3> 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<double> const& decode_array) -> bool
{
static constexpr double expected_decode[8] = {
Expand Down Expand Up @@ -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};
}

}
Expand Down
10 changes: 10 additions & 0 deletions src/render/blend2d_renderer.h
Original file line number Diff line number Diff line change
Expand Up @@ -3385,6 +3385,16 @@ namespace pdflib

inline void renderer<BLEND2D>::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();
Expand Down
66 changes: 66 additions & 0 deletions tests/test_unit_rerender_reduced_bitmaps.py
Original file line number Diff line number Diff line change
@@ -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
)
Loading