phase: 151_pdf_image_extraction
Build and Push Containers / build-and-push-app (push) Successful in 2m24s
Build and Push Containers / build-and-push-db (push) Successful in 13s

The 149/150 CSS changes touch the sources-page sync UI visible behind the modal, so the regenerated screenshot is the accurate record of the current UI (house convention — the suite rewrites it each run; it was re-committed in phases 144/145/148 the same way). I'll keep it in the working tree.

All verification is complete. Final state:

**Phase 151 — final verification pass: complete, project 100% green.**

- **Verified (all 6 task files in `complete/`):** dedupe (xref + digest), SMask→alpha-PNG compositing (raw-format gate first, fail-soft), pure `is_blank_image` gate + log-only counters, `BOR_PDF_IMAGE_SKIP_BLANK` knob (default on), unit + real-file pins, dedicated Playwright suite
- **Real-file pins pass:** `podman-systemd.pdf` → 48 placements, 39 deduped, 9 blank-skipped, **0 image docs out**; `pdf_horse_test.pdf` → masked JPEG composited to alpha PNG, kept; `players_handbook.pdf` → 586 `jpx` images untouched (raw mime, ext-gate skipped)
- **Tests:** `uv run pytest --cov=app --cov-report=term-missing` → **3511 passed, TOTAL 99%** (pdf.py 100%, importer.py 99%, config.py 100%); dedicated E2E `tests/e2e/test_pdf_image_extraction.py` in isolation → **5 passed**; **full E2E sweep: all 135 suites run in isolation, 684 tests, 0 failures**; `uv run ruff check .` clean; `uv run pyright` → 0 errors
- **Completion criteria:** all 7 met (sync `detail` schema untouched — `app/api/sync.py`/`app/schemas.py` unmodified; counters in the shared `import_sources` → `summary.log()` path; commit left to the harness per instructions)
- **Docs:** README.md and AGENTS.md reviewed in full — verified consistent, **no changes needed** (env-var coverage claim, page/route table, production-deploy claims all check out; the phase's README/`.env.example` additions match the code). Note: PLAN.md §7.1's 46rem chat column is superseded by phase 100 (AGENTS.md rule 5 states this correctly; PLAN.md is out of my edit scope)
- **Flagged deviations (documented in-code per rule 3, not silent):** tolerance ±17 vs pinned ±12 — empirically verified ±12 lets the podman page-47 underlay (uniform 0.954) survive, violating criterion #1; ±17 is the smallest band keeping all real-file pins (mean 235 / fraction 0.98 unchanged); gate measures the base stream, not the premultiplied composite (which darkens transparent regions and would miss the underlays)
- **Next pending phase:** `152_sync_progress_image_pass`
This commit is contained in:
2026-10-02 07:16:08 -04:00
parent edf6048a0d
commit c7cfcd26b5
41 changed files with 3952 additions and 85 deletions
+400 -16
View File
@@ -16,10 +16,39 @@ package, imported as ``fitz`` — its stable API name).
value for page N is exactly that page's separator-block body). The
phase-137 image-context block is built from this map (page N's entry
is the document context for an image embedded on page N).
* :func:`extract_pdf_images` — one :class:`PdfImage` per embedded-image
occurrence, in page order then 0-based index on the page. ``data`` is
the image's original encoded bytes as stored in the PDF (no
re-encoding), ``mime`` is derived via :func:`_mime_from_image_info`.
* :func:`extract_pdf_images` — one :class:`PdfImage` per DISTINCT
embedded image (page order, first placement wins) — repeated
placements and byte-identical copies are deduplicated (phase 151
task 01). ``data`` is the image's original encoded bytes as stored in
the PDF (no re-encoding), ``mime`` is derived via
:func:`_mime_from_image_info` — EXCEPT an image stored with a soft
mask (``/SMask``): ``extract_image`` returns the BASE sample stream
only (the mask carries the visible shape), so masked images are
composited to the visible alpha PNG (``mime="image/png"``) — the
bytes a PDF viewer would show (phase 151 task 02); an image whose
raw format is unsupported (the ``application/octet-stream``
fallback, e.g. JPEG2000 ``jpx``) is left untouched (raw bytes, raw
mime) so the importer's extension gate still decides. THEN the
blank/decorative gate (phase 151 task 03, ``skip_blank=True`` by
default — the ``skip_blank=False`` keyword is the function-level
kill switch): a distinct image whose base samples are near-white AND
nearly uniform (:func:`is_blank_image`) is SKIPPED entirely — no
row, no copy, no vision call (ASSUMPTION A1).
* :func:`extract_pdf_images_with_stats` — the stats-carrying variant
the importer calls (phase 151 task 03): the same extraction plus a
:class:`PdfImageStats` (``duplicates`` — occurrences dropped by the
dedupe, ``blank_skipped`` — distinct images dropped by the gate,
``total_found`` — occurrences found) so the run's
``ImportSummary`` can record them log-only (``pdf_images_deduped`` /
``pdf_images_blank_skipped`` — the phase-133 stats convention, the
sync ``detail`` API shape untouched).
* :func:`is_blank_image` — the phase-151 (task 03) blank/decorative
predicate: pure, deterministic, no PDF, no I/O (the
:func:`is_usable_pdf_text` / :func:`pictorial_pages` house style)
— near-white (mean ≥ 235) AND nearly uniform (≥98% of the samples
within ±17 of the mean). A dark uniform block is NOT blank (the gate
targets near-white page underlays); empty samples are not blank
(never blank a thing we cannot measure).
* :func:`_mime_from_image_info` — maps a PyMuPDF image-info dict to a
MIME type: the embedded file extension (``ext``) first, then the
image type code (``type``: 2=JPEG, 3=PNG, 4=GIF, 8=TIFF, 9=WebP),
@@ -50,6 +79,7 @@ never raises for malformed input (the importer owns the loud failures).
"""
from __future__ import annotations
import hashlib
import logging
import re
from collections.abc import Iterator, Sequence
@@ -86,6 +116,45 @@ _EXT_TO_MIME: dict[str, str] = {
"tiff": "image/tiff",
}
#: Phase 151 (task 03) — the blank/decorative image gate's thresholds
#: (ASSUMPTION A2, owner-locked): a PDF-embedded image is
#: blank/decorative when its COLOR samples are NEAR-WHITE AND NEARLY
#: UNIFORM — :func:`is_blank_image`'s three numbers.
#: Measured basis (probe: ``fitz.Pixmap(...).samples`` on the image's
#: BASE sample stream, the three real PDFs in ``test_sources/``):
#
#: * ``podman-systemd.pdf``'s 9 table-shading underlays: mean 238,
#: uniq 8, uniform(±8) 0.95–0.995 → blank (the TODO L4 bug — the
#: "blank white images" the owner saw in the KB);
#: * ``pdf_horse_test.pdf``'s photo: mean 123, uniq 256, uniform ~0.20
#: → kept (the mean gate);
#: * ``players_handbook.pdf``'s page art: mean 24–221, uniq ≥188,
#: uniform 0.10–0.59 → kept (the mean gate).
BLANK_IMAGE_MEAN = 235 #: near-white floor (owner-locked A2)
#: "same as the mean" band (owner-locked A2 — see the CALIBRATED
#: DEVIATION note below: the phase overview's ±12 misses the podman
#: PDF's 238/255-blend underlay, ±17 is the smallest fix).
BLANK_IMAGE_TOLERANCE = 17
#: fraction of samples that must sit in the band (owner-locked A2).
BLANK_IMAGE_FRACTION = 0.98
# CALIBRATED DEVIATION (flagged per AGENTS.md rule 3 — never silent):
# the phase overview pinned the band at ±12, but the podman PDF's
# smallest underlay (page 47, 5 300 samples) is a 238/255 blend whose
# 255 level sits 16.2 above its mean (238.81) — at ±12, 4.5% of its
# samples fall out-of-band (uniform 0.954 < 0.98) and the image
# SURVIVES the gate, violating the phase's completion criterion
# (the extractor must return NO images for that file — the owner's
# file is the phase's reason for existing; a 238/255 strip is the
# very "blank white" document the TODO reported, the table text a
# viewer shows is real PDF text drawn on top). ±17 is the smallest
# integer band that keeps every measured real-file pin: all 9 podman
# underlays blank (99.4–99.9% in-band at ±17), the horse photo kept
# (mean 123 < 235 — the mean gate, tolerance irrelevant), and all 586
# players_handbook images kept (means 17–225 — the mean gate). The
# predicate's formula, the mean floor (235), and the fraction (0.98)
# are the owner-locked values, unchanged.
@dataclass
class PdfImage:
@@ -95,8 +164,33 @@ class PdfImage:
index: int #: image index on the page (0-based, occurrence order)
width: int #: native pixel width
height: int #: native pixel height
data: bytes #: original encoded bytes as stored in the PDF
mime: str #: MIME type (e.g. ``image/png``)
data: bytes #: encoded bytes — the original stored bytes, or the
#: composited (base + soft mask) alpha PNG for masked images
#: (phase 151 task 02)
mime: str #: MIME type (e.g. ``image/png`` — always for composited
#: masked images, phase 151 task 02)
@dataclass(frozen=True)
class PdfImageStats:
"""The per-PDF drop counts of one
:func:`extract_pdf_images_with_stats` call (phase 151, task 03).
The importer records :attr:`duplicates` / :attr:`blank_skipped`
on the run's :class:`app.rag.importer.ImportSummary` (the
phase-133 stats log-only convention — the sync ``detail`` API
shape is untouched).
"""
duplicates: int #: image OCCURRENCES dropped by the dedupe (task
#: 01) — repeated placements + byte-identical copies under
#: distinct xrefs; per run: sum across the PDFs
blank_skipped: int #: DISTINCT images dropped by the
#: blank/decorative gate (task 03) — no row, no image_dir copy,
#: no vision call
total_found: int #: image occurrences FOUND in the PDF (one per
#: placement — before dedupe and gate); hence
#: ``len(images) == total_found - duplicates - blank_skipped``
def _mime_from_image_info(info: dict[str, Any]) -> str:
@@ -308,6 +402,54 @@ def pictorial_pages(page_texts: dict[int, str], *, min_chars: int) -> list[int]:
)
def is_blank_image(
samples: bytes,
*,
mean_min: int = BLANK_IMAGE_MEAN,
tolerance: int = BLANK_IMAGE_TOLERANCE,
fraction: float = BLANK_IMAGE_FRACTION,
) -> bool:
"""Whether *samples* (raw image sample bytes — one byte per sample,
COLOR channels only, no alpha) are a BLANK/DECORATIVE near-white
image (phase 151, task 03).
In the house style of :func:`is_usable_pdf_text` /
:func:`pictorial_pages`: pure, deterministic, NO PDF, NO I/O, NO
LLM — unit-testable with hand-built sample buffers.
Blank when ALL of:
* ``samples`` is non-empty — empty samples → ``False`` (never blank
a thing we cannot measure);
* ``sum(samples) / len(samples) >= mean_min`` — the near-white
floor (default :data:`BLANK_IMAGE_MEAN` = 235). A DARK uniform
block is NOT blank by this rule: the gate targets near-white
PAGE UNDERLAYS (the table-shading rectangles the TODO L4 bug
indexed) — a solid dark block is unusual and cheaper to keep
than to explain away;
* at least ``fraction`` of the samples lie within ``±tolerance``
of the mean (defaults :data:`BLANK_IMAGE_FRACTION` = 0.98,
:data:`BLANK_IMAGE_TOLERANCE` = 17) — the "carries no visible
content" test: a real photo scatters its samples far outside
the band, a decorative underlay sits on one (near-white) level.
Measured basis (the constants' docstring carries the full numbers):
the podman underlays (mean 238, uniq 8, uniform(±8) 0.95–0.995)
are blank; the horse photo (mean 123, uniq 256, uniform ~0.20) and
the handbook page art (mean 24–221, uniq ≥188, uniform 0.10–0.59)
are kept — the mean gate alone carries both real-file "kept" pins,
the fraction+tolerance carry the "near-white but textured" class.
"""
if not samples:
return False
n = len(samples)
mean = sum(samples) / n
if mean < mean_min:
return False
uniform = sum(1 for s in samples if abs(s - mean) <= tolerance) / n
return uniform >= fraction
def render_pdf_pages(
pdf_path: Path, dpi: int = 150, pages: Sequence[int] | None = None
) -> list[bytes]:
@@ -362,23 +504,111 @@ def render_pdf_pages(
return rendered
def extract_pdf_images(pdf_path: Path) -> list[PdfImage]:
"""Extract all embedded images from *pdf_path*.
def extract_pdf_images_with_stats(
pdf_path: Path, *, skip_blank: bool = True
) -> tuple[list[PdfImage], PdfImageStats]:
"""Extract all DISTINCT embedded images from *pdf_path*, with the
per-PDF drop counts (phase 151, task 03 — the stats-carrying
variant the importer calls; :func:`extract_pdf_images` is the thin
list-only wrapper for every other caller).
Returns one :class:`PdfImage` per image occurrence, in page order
then 0-based index on the page. ``data`` is the original encoded
bytes as stored in the PDF; ``mime`` is derived from the image's
extension/type via :func:`_mime_from_image_info`.
Returns ``(images, stats)`` — one :class:`PdfImage` per DISTINCT
embedded image, in page order then 0-based index on the page, plus
the :class:`PdfImageStats` of the drops (``len(images) ==
stats.total_found - stats.duplicates - stats.blank_skipped``). The
FIRST occurrence wins: the kept entry carries the first
placement's ``page``/``index`` (the importer's synthetic path
``…#page=N#img=i.<ext>`` is therefore stable for the first
placement). Two kinds of duplicate are dropped (phase 151, task
01) — PyMuPDF reports one info entry per PLACEMENT, so a PDF that
places the same image N× yields N entries:
Fail-soft: an unreadable file returns ``[]``; an image without an
``xref`` or that cannot be extracted is logged and skipped.
* the same ``xref`` seen again (a repeated placement) — the
``seen_xrefs`` set;
* byte-identical stored bytes under a DIFFERENT ``xref`` (the same
sha256 of ``data``) — the ``seen_digests`` set (real documents DO
store the same image under several xrefs — the digest set is what
catches those; unique-xref count overstates the distinct-image
count).
When anything was dropped, ONE INFO line per PDF after the page
loop (``pdf: dropped N duplicate image occurrence(s) from <path>
(M unique)``) plus one DEBUG line per drop (``pdf: duplicate image
xref=… page=… index=… of <path> — skipped``). Dedupe is PER-PDF —
the seen-sets are local to this call, never across files (two PDFs
sharing a logo each keep their own image doc: the synthetic path is
per-PDF, so cross-PDF dedupe would break the upsert key).
``data`` is the original encoded bytes as stored in the PDF;
``mime`` is derived from the image's extension/type via
:func:`_mime_from_image_info` — with one exception (phase 151,
task 02): an image stored with a soft mask (``/SMask``). PyMuPDF's
``extract_image`` returns the BASE sample stream only — the mask
(which carries the visible shape — e.g. the table-shading underlay
the owner's podman PDF stores as a near-white base) is dropped — so
for such images the extractor composites base + mask into the
VISIBLE image (``fitz.Pixmap(base, mask)`` → ``data =
visible.tobytes("png")``, ``mime = "image/png"``): the bytes a PDF
viewer shows, not the blank base. Two rules:
* the RAW-format gate runs FIRST (owner-locked A4): an image whose
raw format the importer's ``_MIME_TO_EXT`` gate rejects (the
``application/octet-stream`` fallback — e.g. JPEG2000 ``jpx``,
the 586 images in ``players_handbook.pdf``) is NOT composited —
raw bytes + raw mime, byte-identical to pre-phase; compositing
must never newly admit an unsupported format (a ``jpx`` image
with a mask stays skipped);
* the compositing is fail-soft: any exception (a colorspace the
combiner cannot merge, a broken mask xref) falls back to the raw
bytes + raw mime with a warning — the image is still indexed,
exactly as pre-phase (a decode problem never loses a real
image). ``width``/``height`` are unchanged either way (the
composited pixmap has the base's dimensions).
THEN the blank/decorative gate (phase 151, task 03, gated by
*skip_blank* — the kill switch at the function level; task 04's
``BOR_PDF_IMAGE_SKIP_BLANK`` setting lands here, dedupe and mask
compositing are NOT knobed): a distinct image whose samples are
near-white AND nearly uniform (:func:`is_blank_image`) is SKIPPED
entirely — not returned (ASSUMPTION A1: never indexed, never
described — no row, no ``image_dir`` copy, no vision call) — with
one INFO line per skipped image (``pdf: skipped blank/decorative
image page=… index=… of <path> — mean=… uniform=…``) and one INFO
line per PDF when anything was skipped (``pdf: skipped N
blank/decorative image(s) in <path>``). The gate measures the
image's BASE sample stream (``fitz.Pixmap(doc, xref)``, the alpha
channel dropped first when present — the mean is over COLOR
samples only, an alpha channel would drag the mean and produce
false blanks) for every image — masked or not: for a masked image
the base is the correct "visible content" signal, because the
``/SMask`` only modulates the base's VISIBILITY (a near-white base
renders near-white over the white page under ANY mask, so a
near-white base is decorative no matter what the mask says) — and
the stored composite's color channels are PyMuPDF's
PREMULTIPLIED base×mask/255, which darkens the transparent regions
and would miss the very near-white underlays this gate targets
(measured on the podman underlays: alpha-stripped composite
mean ≈ 231–234 / uniform ≈ 0.95 vs base mean ≈ 238 / uniform
≈ 0.99 — only the base clears the pinned thresholds on every
underlay). The gate is fail-soft: a pixmap that cannot be built
skips the gate (the image is KEPT, DEBUG why) — a decode problem
must never delete a real image.
Fail-soft: an unreadable file returns ``([], PdfImageStats(0, 0,
0))``; an image without an ``xref`` or that cannot be extracted is
logged and skipped.
"""
try:
doc = fitz.open(str(pdf_path))
except Exception as exc:
logger.warning("pdf: could not open %s for image extraction — %s", pdf_path, exc)
return []
return [], PdfImageStats(duplicates=0, blank_skipped=0, total_found=0)
seen_xrefs: set[int] = set()
seen_digests: set[str] = set()
dropped = 0
blank_skipped = 0
total_found = 0
images: list[PdfImage] = []
with doc:
for page_num in range(len(doc)):
@@ -413,10 +643,133 @@ def extract_pdf_images(pdf_path: Path) -> list[PdfImage]:
page_num + 1, index, pdf_path,
)
continue
# One image occurrence found (valid xref, extracted,
# non-empty data) — the input to dedupe + gate (task
# 03's total_found; len(images) = total_found -
# duplicates - blank_skipped).
total_found += 1
# Phase 151 (task 01): one PdfImage per DISTINCT image.
# Repeated placements share the xref (the first check);
# byte-identical bytes stored under a different xref are
# caught by the digest of the stored bytes (the second).
if xref in seen_xrefs:
dropped += 1
logger.debug(
"pdf: duplicate image xref=%d page=%d index=%d of %s — skipped",
xref, page_num + 1, index, pdf_path,
)
continue
digest = hashlib.sha256(data).hexdigest()
if digest in seen_digests:
dropped += 1
logger.debug(
"pdf: duplicate image xref=%d page=%d index=%d of %s — skipped",
xref, page_num + 1, index, pdf_path,
)
continue
seen_xrefs.add(xref)
seen_digests.add(digest)
# The image info's own `ext` is unreliable in some
# PyMuPDF versions (None); `extract_image` reports the
# actual format of the returned bytes, so it wins.
mime = _mime_from_image_info({**info, "ext": extracted.get("ext") or ""})
# Phase 151 (task 02): a /SMask carries the visible
# shape — `extract_image` returns the BASE sample
# stream only (the mask is dropped), so the raw bytes
# are not what a PDF viewer shows. Composite base +
# mask to the visible alpha PNG.
#
# Ordering rule (owner-locked A4): the raw-format gate
# runs FIRST — an image whose raw format the importer's
# `_MIME_TO_EXT` gate will reject (the
# `application/octet-stream` fallback — e.g. the 586
# JPEG2000 `jpx` images in players_handbook.pdf) is
# left EXACTLY as today (raw bytes, raw mime, skipped
# downstream): compositing must never newly admit an
# unsupported format.
smask = int(extracted.get("smask") or 0)
# The image's BASE pixmap, kept for the blank gate
# (task 03) when the compositing built it (a second
# decode of the same xref for the gate would be wasted
# work; when the compositing never ran — no mask or an
# unsupported raw format — the gate builds it itself).
base_pixmap: Any = None
if smask and mime != "application/octet-stream":
try:
base = fitz.Pixmap(doc, xref)
base_pixmap = base
mask = fitz.Pixmap(doc, smask)
visible = fitz.Pixmap(base, mask) # base + alpha-from-mask
data = visible.tobytes("png")
mime = "image/png"
logger.debug(
"pdf: composited smask=%d for image page=%d index=%d of %s",
smask, page_num + 1, index, pdf_path,
)
except Exception as exc:
# Fail-soft: `data`/`mime` keep their raw
# values — the image is still indexed, exactly
# as pre-phase (a decode problem never loses a
# real image).
logger.warning(
"pdf: could not composite smask=%d for image page=%d "
"index=%d of %s — %s; using raw bytes",
smask, page_num + 1, index, pdf_path, exc,
)
# Phase 151 (task 03): the blank/decorative gate — a
# near-white, nearly-uniform image (a table-shading
# underlay) carries no visible content: skip it
# entirely (ASSUMPTION A1 — no row, no copy, no vision
# call). Gated by `skip_blank` (the function-level kill
# switch; task 04's settings knob lands here) — dedupe
# and mask compositing above are NOT knobed.
if skip_blank:
try:
# The BASE sample stream (the docstring's gate-
# input rule): the base's color content is what
# the image SHOWS (a mask only modulates its
# visibility), and PyMuPDF's stored composite
# is premultiplied (base×mask/255) — darkened
# in the transparent regions, where the gate
# would miss the very underlays it targets.
gate_pixmap = (
base_pixmap
if base_pixmap is not None
else fitz.Pixmap(doc, xref)
)
if gate_pixmap.alpha:
# Drop the alpha FIRST: the mean is over
# COLOR samples only (an alpha channel
# would drag the mean and produce false
# blanks).
gate_pixmap = fitz.Pixmap(gate_pixmap, 0)
samples = gate_pixmap.samples
if is_blank_image(samples):
blank_skipped += 1
mean = sum(samples) / len(samples)
uniform = (
sum(
1
for s in samples
if abs(s - mean) <= BLANK_IMAGE_TOLERANCE
)
/ len(samples)
)
logger.info(
"pdf: skipped blank/decorative image page=%d "
"index=%d of %s — mean=%.1f uniform=%.3f",
page_num + 1, index, pdf_path, mean, uniform,
)
continue
except Exception as exc:
# Fail-soft: a pixmap that cannot be built
# skips the gate (the image is KEPT — a decode
# problem must never delete a real image).
logger.debug(
"pdf: blank gate could not build pixmap page=%d "
"index=%d of %s — %s; keeping image",
page_num + 1, index, pdf_path, exc,
)
images.append(
PdfImage(
page=page_num + 1,
@@ -427,4 +780,35 @@ def extract_pdf_images(pdf_path: Path) -> list[PdfImage]:
mime=mime,
)
)
return images
if dropped:
logger.info(
"pdf: dropped %d duplicate image occurrence(s) from %s (%d unique)",
dropped, pdf_path, len(images),
)
if blank_skipped:
logger.info(
"pdf: skipped %d blank/decorative image(s) in %s",
blank_skipped, pdf_path,
)
return images, PdfImageStats(
duplicates=dropped, blank_skipped=blank_skipped, total_found=total_found
)
def extract_pdf_images(
pdf_path: Path, *, skip_blank: bool = True
) -> list[PdfImage]:
"""Extract all DISTINCT embedded images from *pdf_path*.
Thin wrapper over :func:`extract_pdf_images_with_stats` (phase 151,
task 03) returning the image list only — the pre-task-03 contract
for every other caller. *skip_blank* (default ON) passes straight
through: the kill switch at the function level —
``extract_pdf_images(path, skip_blank=False)`` restores the
pre-phase result (every distinct image returned, gate off; the
dedupe and mask compositing stay on — they are correctness fixes,
not knobed). The importer calls the ``_with_stats`` variant so the
run's :class:`~app.rag.importer.ImportSummary` can record the drop
counts (the phase-133 stats log-only convention).
"""
return extract_pdf_images_with_stats(pdf_path, skip_blank=skip_blank)[0]