Compare commits

..

No commits in common. "master" and "2.1.0" have entirely different histories.

24 changed files with 2278 additions and 37584 deletions

View File

@ -5,4 +5,4 @@
url = ssh://vector.iqser.com/research/image-prediction/ url = ssh://vector.iqser.com/research/image-prediction/
port = 22 port = 22
['remote "azure_remote"'] ['remote "azure_remote"']
url = azure://image-classification-dvc/ url = azure://ic-sa-dvc/

View File

@ -1,51 +1,31 @@
include: include:
- project: "Gitlab/gitlab" - project: "Gitlab/gitlab"
ref: main ref: 0.3.0
file: "/ci-templates/research/dvc.gitlab-ci.yml" file: "/ci-templates/research/dvc-versioning-build-release.gitlab-ci.yml"
- project: "Gitlab/gitlab"
ref: main
file: "/ci-templates/research/versioning-build-test-release.gitlab-ci.yml"
variables: variables:
NEXUS_PROJECT_DIR: red NEXUS_PROJECT_DIR: red
IMAGENAME: "${CI_PROJECT_NAME}" IMAGENAME: "${CI_PROJECT_NAME}"
INTEGRATION_TEST_FILE: "${CI_PROJECT_ID}.pdf" INTEGRATION_TEST_FILE: "${CI_PROJECT_ID}.pdf"
FF_USE_FASTZIP: "true" # enable fastzip - a faster zip implementation that also supports level configuration.
ARTIFACT_COMPRESSION_LEVEL: default # can also be set to fastest, fast, slow and slowest. If just enabling fastzip is not enough try setting this to fastest or fast.
CACHE_COMPRESSION_LEVEL: default # same as above, but for caches
# TRANSFER_METER_FREQUENCY: 5s # will display transfer progress every 5 seconds for artifacts and remote caches. For debugging purposes.
stages: #################################
- data # temp. disable integration tests, b/c they don't cover the CV analysis case yet
- setup trigger integration tests:
- tests
- sonarqube
- versioning
- build
- integration-tests
- release
docker-build:
extends: .docker-build
needs:
- job: dvc-pull
artifacts: true
- !reference [.needs-versioning, needs] # leave this line as is
###################
# INTEGRATION TESTS
trigger-integration-tests:
extends: .integration-tests
# ADD THE MODEL BUILD WHICH SHOULD TRIGGER THE INTEGRATION TESTS
# needs:
# - job: docker-build::model_name
# artifacts: true
rules: rules:
- when: never - when: never
######### release build:
# RELEASE stage: release
release:
extends: .release
needs: needs:
- !reference [.needs-versioning, needs] # leave this line as is - job: set custom version
artifacts: true
optional: true
- job: calculate patch version
artifacts: true
optional: true
- job: calculate minor version
artifacts: true
optional: true
- job: build docker nexus
artifacts: true
#################################

View File

@ -1 +1 @@
3.10 3.10.12

View File

@ -1,17 +1,11 @@
FROM python:3.10-slim AS builder FROM python:3.10
ARG GITLAB_USER
ARG GITLAB_ACCESS_TOKEN
ARG USERNAME
ARG TOKEN
ARG PYPI_REGISTRY_RESEARCH=https://gitlab.knecon.com/api/v4/groups/19/-/packages/pypi ARG PYPI_REGISTRY_RESEARCH=https://gitlab.knecon.com/api/v4/groups/19/-/packages/pypi
ARG POETRY_SOURCE_REF_RESEARCH=gitlab-research ARG POETRY_SOURCE_REF_RESEARCH=gitlab-research
ARG PYPI_REGISTRY_RED=https://gitlab.knecon.com/api/v4/groups/12/-/packages/pypi ARG PYPI_REGISTRY_RED=https://gitlab.knecon.com/api/v4/groups/12/-/packages/pypi
ARG POETRY_SOURCE_REF_RED=gitlab-red ARG POETRY_SOURCE_REF_RED=gitlab-red
ARG PYPI_REGISTRY_FFORESIGHT=https://gitlab.knecon.com/api/v4/groups/269/-/packages/pypi
ARG POETRY_SOURCE_REF_FFORESIGHT=gitlab-fforesight
ARG VERSION=dev ARG VERSION=dev
LABEL maintainer="Research <research@knecon.com>" LABEL maintainer="Research <research@knecon.com>"
@ -19,55 +13,27 @@ LABEL version="${VERSION}"
WORKDIR /app WORKDIR /app
###########
# ENV SETUP
ENV PYTHONDONTWRITEBYTECODE=true
ENV PYTHONUNBUFFERED=true ENV PYTHONUNBUFFERED=true
ENV POETRY_HOME=/opt/poetry ENV POETRY_HOME=/opt/poetry
ENV PATH="$POETRY_HOME/bin:$PATH" ENV PATH="$POETRY_HOME/bin:$PATH"
RUN apt-get update && \
apt-get install -y curl git bash build-essential libffi-dev libssl-dev && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
RUN curl -sSL https://install.python-poetry.org | python3 - RUN curl -sSL https://install.python-poetry.org | python3 -
RUN poetry --version
COPY pyproject.toml poetry.lock ./ COPY ./data ./data
COPY ./config ./config
COPY ./src ./src
COPY pyproject.toml poetry.lock banner.txt ./
RUN poetry config virtualenvs.create true && \ RUN poetry config virtualenvs.create false && \
poetry config virtualenvs.in-project true && \
poetry config installer.max-workers 10 && \ poetry config installer.max-workers 10 && \
poetry config repositories.${POETRY_SOURCE_REF_RESEARCH} ${PYPI_REGISTRY_RESEARCH} && \ poetry config repositories.${POETRY_SOURCE_REF_RESEARCH} ${PYPI_REGISTRY_RESEARCH} && \
poetry config http-basic.${POETRY_SOURCE_REF_RESEARCH} ${GITLAB_USER} ${GITLAB_ACCESS_TOKEN} && \ poetry config http-basic.${POETRY_SOURCE_REF_RESEARCH} ${USERNAME} ${TOKEN} && \
poetry config repositories.${POETRY_SOURCE_REF_RED} ${PYPI_REGISTRY_RED} && \ poetry config repositories.${POETRY_SOURCE_REF_RED} ${PYPI_REGISTRY_RED} && \
poetry config http-basic.${POETRY_SOURCE_REF_RED} ${GITLAB_USER} ${GITLAB_ACCESS_TOKEN} && \ poetry config http-basic.${POETRY_SOURCE_REF_RED} ${USERNAME} ${TOKEN} && \
poetry config repositories.${POETRY_SOURCE_REF_FFORESIGHT} ${PYPI_REGISTRY_FFORESIGHT} && \ poetry install --without=dev -vv --no-interaction
poetry config http-basic.${POETRY_SOURCE_REF_FFORESIGHT} ${GITLAB_USER} ${GITLAB_ACCESS_TOKEN} && \
poetry install --without=dev -vv --no-interaction --no-root
###############
# WORKING IMAGE
FROM python:3.10-slim
WORKDIR /app
# COPY SOURCE CODE FROM BUILDER IMAGE
COPY --from=builder /app /app
# COPY BILL OF MATERIALS (BOM)
COPY bom.json /bom.json
ENV PATH="/app/.venv/bin:$PATH"
###################
# COPY SOURCE CODE
COPY ./src ./src
COPY ./config ./config
COPY ./data ./data
COPY banner.txt ./
EXPOSE 5000 EXPOSE 5000
EXPOSE 8080 EXPOSE 8080
CMD [ "python", "src/serve.py"] CMD [ "python", "src/serve.py"]

33697
bom.json

File diff suppressed because it is too large Load Diff

View File

@ -1,20 +1,9 @@
[asyncio]
max_concurrent_tasks = 10
[dynamic_tenant_queues]
enabled = true
[metrics.prometheus] [metrics.prometheus]
enabled = true enabled = true
prefix = "redactmanager_image_service" prefix = "redactmanager_image_service"
[tracing]
enabled = true
# possible values "opentelemetry" | "azure_monitor" (Excpects APPLICATIONINSIGHTS_CONNECTION_STRING environment variable.)
type = "azure_monitor"
[tracing.opentelemetry] [tracing.opentelemetry]
enabled = true
endpoint = "http://otel-collector-opentelemetry-collector.otel-collector:4318/v1/traces" endpoint = "http://otel-collector-opentelemetry-collector.otel-collector:4318/v1/traces"
service_name = "redactmanager_image_service" service_name = "redactmanager_image_service"
exporter = "otlp" exporter = "otlp"
@ -36,16 +25,6 @@ input_queue = "request_queue"
output_queue = "response_queue" output_queue = "response_queue"
dead_letter_queue = "dead_letter_queue" dead_letter_queue = "dead_letter_queue"
tenant_event_queue_suffix = "_tenant_event_queue"
tenant_event_dlq_suffix = "_tenant_events_dlq"
tenant_exchange_name = "tenants-exchange"
queue_expiration_time = 300000 # 5 minutes in milliseconds
service_request_queue_prefix = "image_request_queue"
service_request_exchange_name = "image_request_exchange"
service_response_exchange_name = "image_response_exchange"
service_dlq_name = "image_dlq"
[storage] [storage]
backend = "s3" backend = "s3"
@ -63,6 +42,3 @@ connection_string = ""
[storage.tenant_server] [storage.tenant_server]
public_key = "" public_key = ""
endpoint = "http://tenant-user-management:8081/internal-api/tenants" endpoint = "http://tenant-user-management:8081/internal-api/tenants"
[kubernetes]
pod_name = "test_pod"

View File

@ -4,39 +4,25 @@ level = "INFO"
[service] [service]
# Print document processing progress to stdout # Print document processing progress to stdout
verbose = false verbose = false
batch_size = 6 batch_size = 16
image_stiching_tolerance = 1 # in pixels
mlflow_run_id = "fabfb1f192c745369b88cab34471aba7" mlflow_run_id = "fabfb1f192c745369b88cab34471aba7"
# These variables control filters that are applied to either images, image metadata or service_estimator predictions. # These variables control filters that are applied to either images, image metadata or service_estimator predictions.
# The filter result values are reported in the service responses. For convenience the response to a request contains a # The filter result values are reported in the service responses. For convenience the response to a request contains a
# "filters.allPassed" field, which is set to false if any of the values returned by the filters did not meet its # "filters.allPassed" field, which is set to false if any of the values returned by the filters did not meet its
# specified required value. # specified required value.
[filters.confidence] [filters]
# Minimum permissible prediction confidence # Minimum permissible prediction confidence
min = 0.5 min_confidence = 0.5
# Image size to page size ratio (ratio of geometric means of areas) # Image size to page size ratio (ratio of geometric means of areas)
[filters.image_to_page_quotient] [filters.image_to_page_quotient]
min = 0.05 min = 0.05
max = 0.75 max = 0.75
[filters.is_scanned_page]
# Minimum permissible image to page ratio tolerance for a page to be considered scanned.
# This is only used for filtering small images on scanned pages and is applied before processing the image, therefore
# superseding the image_to_page_quotient filter that only applies a tag to the image after processing.
tolerance = 0
# Image width to height ratio # Image width to height ratio
[filters.image_width_to_height_quotient] [filters.image_width_to_height_quotient]
min = 0.1 min = 0.1
max = 10 max = 10
# put class specific filters here ['signature', 'formula', 'logo']
[filters.overrides.signature.image_to_page_quotient]
max = 0.4
[filters.overrides.logo.image_to_page_quotient]
min = 0.06

5606
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "image-classification-service" name = "image-classification-service"
version = "2.17.0" version = "2.0.0"
description = "" description = ""
authors = ["Team Research <research@knecon.com>"] authors = ["Team Research <research@knecon.com>"]
readme = "README.md" readme = "README.md"
@ -8,10 +8,8 @@ packages = [{ include = "image_prediction", from = "src" }]
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = ">=3.10,<3.11" python = ">=3.10,<3.11"
# FIXME: This should be recent pyinfra, but the recent protobuf packages are not compatible with tensorflow 2.9.0, also pyinfra = { version = "2.0.0", source = "gitlab-research" }
# see RED-9948. kn-utils = { version = "0.2.7", source = "gitlab-research" }
pyinfra = { version = "3.4.2", source = "gitlab-research" }
kn-utils = { version = ">=0.4.0", source = "gitlab-research" }
dvc = "^2.34.0" dvc = "^2.34.0"
dvc-ssh = "^2.20.0" dvc-ssh = "^2.20.0"
dvc-azure = "^2.21.2" dvc-azure = "^2.21.2"
@ -25,10 +23,7 @@ mlflow = "^1.24.0"
numpy = "^1.22.3" numpy = "^1.22.3"
tqdm = "^4.64.0" tqdm = "^4.64.0"
pandas = "^1.4.2" pandas = "^1.4.2"
# FIXME: Our current model significantly changes the prediction behaviour when using newer tensorflow (/ protobuf) tensorflow = "^2.8.0"
# versions which is introduuced by pyinfra updates using newer protobuf versions, see RED-9948.
tensorflow = "2.9.0"
protobuf = "^3.20"
pytest = "^7.1.0" pytest = "^7.1.0"
funcy = "^2" funcy = "^2"
PyMuPDF = "^1.19.6" PyMuPDF = "^1.19.6"
@ -37,11 +32,11 @@ coverage = "^6.3.2"
Pillow = "^9.1.0" Pillow = "^9.1.0"
pdf2image = "^1.16.0" pdf2image = "^1.16.0"
frozendict = "^2.3.0" frozendict = "^2.3.0"
protobuf = "^3.20.0"
fsspec = "^2022.11.0" fsspec = "^2022.11.0"
PyMonad = "^2.4.0" PyMonad = "^2.4.0"
pdfnetpython3 = "9.4.2" pdfnetpython3 = "9.4.2"
loguru = "^0.7.0" loguru = "^0.7.0"
cyclonedx-bom = "^4.5.0"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
pytest = "^7.0.1" pytest = "^7.0.1"

View File

@ -1,46 +0,0 @@
"""Script to debug RED-9948. The predictions unexpectedly changed for some images, and we need to understand why."""
import json
import random
from pathlib import Path
import numpy as np
import tensorflow as tf
from kn_utils.logging import logger
from image_prediction.config import CONFIG
from image_prediction.pipeline import load_pipeline
def process_pdf(pipeline, pdf_path, page_range=None):
with open(pdf_path, "rb") as f:
logger.info(f"Processing {pdf_path}")
predictions = list(pipeline(f.read(), page_range=page_range))
return predictions
def ensure_seeds():
seed = 42
np.random.seed(seed)
random.seed(seed)
tf.random.set_seed(seed)
def debug_info():
devices = tf.config.list_physical_devices()
print("Available devices:", devices)
if __name__ == "__main__":
# For in container debugging, copy the file and adjust the path.
debug_file_path = Path(__file__).parents[2] / "test" / "data" / "RED-9948" / "SYNGENTA_EFSA_sanitisation_GFL_v2"
ensure_seeds()
debug_info()
pipeline = load_pipeline(verbose=True, batch_size=CONFIG.service.batch_size)
predictions = process_pdf(pipeline, debug_file_path)
# This is the image that has the wrong prediction mentioned in RED-9948. The predictions should inconclusive, and
# the flag all passed should be false.
predictions = [x for x in predictions if x["representation"] == "FA30F080F0C031CE17E8CF237"]
print(json.dumps(predictions, indent=2))

View File

@ -1,6 +1,6 @@
docker build -t --platform linux/amd64 image-clsasification-service:$(poetry version -s)-dev \ docker build -t image-clsasification-service:$(poetry version -s)-dev \
-f Dockerfile \ -f Dockerfile \
--build-arg GITLAB_USER=$GITLAB_USER \ --build-arg USERNAME=$GITLAB_USER \
--build-arg GITLAB_ACCESS_TOKEN=$GITLAB_ACCESS_TOKEN \ --build-arg TOKEN=$GITLAB_ACCESS_TOKEN \
. && \ . && \
docker run -it --rm image-clsasification-service:$(poetry version -s)-dev docker run -it --rm image-clsasification-service:$(poetry version -s)-dev

View File

@ -3,15 +3,12 @@ import json
import os import os
from glob import glob from glob import glob
from image_prediction.config import CONFIG
from image_prediction.pipeline import load_pipeline from image_prediction.pipeline import load_pipeline
from image_prediction.utils import get_logger from image_prediction.utils import get_logger
from image_prediction.utils.pdf_annotation import annotate_pdf from image_prediction.utils.pdf_annotation import annotate_pdf
logger = get_logger() logger = get_logger()
logger.setLevel("DEBUG")
def parse_args(): def parse_args():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
@ -38,7 +35,7 @@ def process_pdf(pipeline, pdf_path, page_range=None):
def main(args): def main(args):
pipeline = load_pipeline(verbose=CONFIG.service.verbose, batch_size=CONFIG.service.batch_size, tolerance=CONFIG.service.image_stiching_tolerance) pipeline = load_pipeline(verbose=True, tolerance=3)
if os.path.isfile(args.input): if os.path.isfile(args.input):
pdf_paths = [args.input] pdf_paths = [args.input]

View File

@ -13,7 +13,7 @@ class HashEncoder(Encoder):
yield from self.encode(images) yield from self.encode(images)
def hash_image(image: Image.Image) -> str: def hash_image(image: Image.Image):
"""See: https://stackoverflow.com/a/49692185/3578468""" """See: https://stackoverflow.com/a/49692185/3578468"""
image = image.resize((10, 10), Image.ANTIALIAS) image = image.resize((10, 10), Image.ANTIALIAS)
image = image.convert("L") image = image.convert("L")
@ -21,6 +21,4 @@ def hash_image(image: Image.Image) -> str:
avg_pixel = sum(pixel_data) / len(pixel_data) avg_pixel = sum(pixel_data) / len(pixel_data)
bits = "".join(["1" if (px >= avg_pixel) else "0" for px in pixel_data]) bits = "".join(["1" if (px >= avg_pixel) else "0" for px in pixel_data])
hex_representation = str(hex(int(bits, 2)))[2:][::-1].upper() hex_representation = str(hex(int(bits, 2)))[2:][::-1].upper()
# Note: For each 4 leading zeros, the hex representation will be shorter by one character. return hex_representation
# To ensure that all hashes have the same length, we pad the hex representation with zeros (also see RED-3813).
return hex_representation.zfill(25)

View File

@ -3,7 +3,7 @@ import json
import traceback import traceback
from _operator import itemgetter from _operator import itemgetter
from functools import partial, lru_cache from functools import partial, lru_cache
from itertools import chain, starmap, filterfalse, tee from itertools import chain, starmap, filterfalse
from operator import itemgetter, truth from operator import itemgetter, truth
from typing import Iterable, Iterator, List, Union from typing import Iterable, Iterator, List, Union
@ -11,10 +11,9 @@ import fitz
import numpy as np import numpy as np
from PIL import Image from PIL import Image
from funcy import merge, pluck, compose, rcompose, remove, keep from funcy import merge, pluck, compose, rcompose, remove, keep
from scipy.stats import gmean
from image_prediction.config import CONFIG from image_prediction.config import CONFIG
from image_prediction.exceptions import InvalidBox from image_prediction.exceptions import InvalidBox, BadXref
from image_prediction.formatter.formatters.enum import EnumFormatter from image_prediction.formatter.formatters.enum import EnumFormatter
from image_prediction.image_extractor.extractor import ImageExtractor, ImageMetadataPair from image_prediction.image_extractor.extractor import ImageExtractor, ImageMetadataPair
from image_prediction.info import Info from image_prediction.info import Info
@ -35,7 +34,7 @@ class ParsablePDFImageExtractor(ImageExtractor):
tolerance: The tolerance in pixels for the distance between images, beyond which they will not be stitched tolerance: The tolerance in pixels for the distance between images, beyond which they will not be stitched
together together
""" """
self.doc: fitz.Document = None self.doc: fitz.fitz.Document = None
self.verbose = verbose self.verbose = verbose
self.tolerance = tolerance self.tolerance = tolerance
@ -48,7 +47,7 @@ class ParsablePDFImageExtractor(ImageExtractor):
yield from image_metadata_pairs yield from image_metadata_pairs
def __process_images_on_page(self, page: fitz.Page): def __process_images_on_page(self, page: fitz.fitz.Page):
metadata = extract_valid_metadata(self.doc, page) metadata = extract_valid_metadata(self.doc, page)
images = get_images_on_page(self.doc, metadata) images = get_images_on_page(self.doc, metadata)
@ -65,13 +64,9 @@ class ParsablePDFImageExtractor(ImageExtractor):
@staticmethod @staticmethod
def __filter_valid_images(image_metadata_pairs: Iterable[ImageMetadataPair]) -> Iterator[ImageMetadataPair]: def __filter_valid_images(image_metadata_pairs: Iterable[ImageMetadataPair]) -> Iterator[ImageMetadataPair]:
def validate_image_is_not_corrupt(image: Image.Image, metadata: dict): def validate(image: Image.Image, metadata: dict):
"""See RED-5148: Some images are corrupt and cannot be processed by the image classifier. This function
filters out such images by trying to resize and convert them to RGB. If this fails, the image is considered
corrupt and is dropped.
TODO: find cleaner solution
"""
try: try:
# TODO: stand-in heuristic for testing if image is valid => find cleaner solution (RED-5148)
image.resize((100, 100)).convert("RGB") image.resize((100, 100)).convert("RGB")
return ImageMetadataPair(image, metadata) return ImageMetadataPair(image, metadata)
except (OSError, Exception) as err: except (OSError, Exception) as err:
@ -79,41 +74,7 @@ class ParsablePDFImageExtractor(ImageExtractor):
logger.warning(f"Invalid image encountered. Image metadata:\n{metadata}\n\n{traceback.format_exc()}") logger.warning(f"Invalid image encountered. Image metadata:\n{metadata}\n\n{traceback.format_exc()}")
return None return None
def filter_small_images_on_scanned_pages(image_metadata_pairs) -> Iterable[ImageMetadataPair]: return filter(truth, starmap(validate, image_metadata_pairs))
"""See RED-9746: Small images on scanned pages should be dropped, so they are not classified. This is a
heuristic to filter out images that are too small in relation to the page size if they are on a scanned page.
The ratio is computed as the geometric mean of the width and height of the image divided by the geometric mean
of the width and height of the page. If the ratio is below the threshold, the image is dropped.
"""
def image_is_a_scanned_page(image_metadata_pair: ImageMetadataPair) -> bool:
tolerance = CONFIG.filters.is_scanned_page.tolerance
width_ratio = image_metadata_pair.metadata[Info.WIDTH] / image_metadata_pair.metadata[Info.PAGE_WIDTH]
height_ratio = (
image_metadata_pair.metadata[Info.HEIGHT] / image_metadata_pair.metadata[Info.PAGE_HEIGHT]
)
return width_ratio >= 1 - tolerance and height_ratio >= 1 - tolerance
def image_fits_geometric_mean_ratio(image_metadata_pair: ImageMetadataPair) -> bool:
min_ratio = CONFIG.filters.image_to_page_quotient.min
metadatum = image_metadata_pair.metadata
image_gmean = gmean([metadatum[Info.WIDTH], metadatum[Info.HEIGHT]])
page_gmean = gmean([metadatum[Info.PAGE_WIDTH], metadatum[Info.PAGE_HEIGHT]])
ratio = image_gmean / page_gmean
return ratio >= min_ratio
pairs, pairs_copy = tee(image_metadata_pairs)
if any(map(image_is_a_scanned_page, pairs_copy)):
logger.debug("Scanned page detected, filtering out small images ...")
return filter(image_fits_geometric_mean_ratio, pairs)
else:
return pairs
image_metadata_pairs = filter_small_images_on_scanned_pages(image_metadata_pairs)
return filter(truth, starmap(validate_image_is_not_corrupt, image_metadata_pairs))
def extract_pages(doc, page_range): def extract_pages(doc, page_range):
@ -130,7 +91,7 @@ def get_images_on_page(doc, metadata):
yield from images yield from images
def extract_valid_metadata(doc: fitz.Document, page: fitz.Page): def extract_valid_metadata(doc: fitz.fitz.Document, page: fitz.fitz.Page):
metadata = get_metadata_for_images_on_page(page) metadata = get_metadata_for_images_on_page(page)
metadata = filter_valid_metadata(metadata) metadata = filter_valid_metadata(metadata)
metadata = add_alpha_channel_info(doc, metadata) metadata = add_alpha_channel_info(doc, metadata)
@ -138,6 +99,7 @@ def extract_valid_metadata(doc: fitz.Document, page: fitz.Page):
return list(metadata) return list(metadata)
def get_metadata_for_images_on_page(page: fitz.Page): def get_metadata_for_images_on_page(page: fitz.Page):
metadata = map(get_image_metadata, get_image_infos(page)) metadata = map(get_image_metadata, get_image_infos(page))
metadata = add_page_metadata(page, metadata) metadata = add_page_metadata(page, metadata)
@ -191,7 +153,7 @@ def xref_to_image(doc, xref) -> Union[Image.Image, None]:
return return
def convert_pixmap_to_array(pixmap: fitz.Pixmap): def convert_pixmap_to_array(pixmap: fitz.fitz.Pixmap):
array = np.frombuffer(pixmap.samples, dtype=np.uint8).reshape(pixmap.h, pixmap.w, pixmap.n) array = np.frombuffer(pixmap.samples, dtype=np.uint8).reshape(pixmap.h, pixmap.w, pixmap.n)
array = _normalize_channels(array) array = _normalize_channels(array)
return array return array
@ -210,6 +172,7 @@ def _normalize_channels(array: np.ndarray):
def get_image_metadata(image_info): def get_image_metadata(image_info):
xref, coords = itemgetter("xref", "bbox")(image_info) xref, coords = itemgetter("xref", "bbox")(image_info)
x1, y1, x2, y2 = map(rounder, coords) x1, y1, x2, y2 = map(rounder, coords)
@ -265,6 +228,7 @@ def get_page_metadata(page):
def has_alpha_channel(doc, xref): def has_alpha_channel(doc, xref):
maybe_image = load_image_handle_from_xref(doc, xref) maybe_image = load_image_handle_from_xref(doc, xref)
maybe_smask = maybe_image["smask"] if maybe_image else None maybe_smask = maybe_image["smask"] if maybe_image else None

View File

@ -1,10 +1,8 @@
import os import os
from functools import lru_cache, partial from functools import lru_cache, partial
from itertools import chain, tee from itertools import chain, tee
from typing import Iterable, Any
from funcy import rcompose, first, compose, second, chunks, identity, rpartial from funcy import rcompose, first, compose, second, chunks, identity, rpartial
from kn_utils.logging import logger
from tqdm import tqdm from tqdm import tqdm
from image_prediction.config import CONFIG from image_prediction.config import CONFIG
@ -23,7 +21,6 @@ os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3"
@lru_cache(maxsize=None) @lru_cache(maxsize=None)
def load_pipeline(**kwargs): def load_pipeline(**kwargs):
logger.info(f"Loading pipeline with kwargs: {kwargs}")
model_loader = get_mlflow_model_loader(MLRUNS_DIR) model_loader = get_mlflow_model_loader(MLRUNS_DIR)
model_identifier = CONFIG.service.mlflow_run_id model_identifier = CONFIG.service.mlflow_run_id
@ -55,7 +52,7 @@ class Pipeline:
join = compose(starlift(lambda prd, rpr, mdt: {"classification": prd, **mdt, "representation": rpr}), star(zip)) join = compose(starlift(lambda prd, rpr, mdt: {"classification": prd, **mdt, "representation": rpr}), star(zip))
# />--classify--\ # />--classify--\
# --extract-->--split--+->--encode---->+--join-->reformat-->filter_duplicates # --extract-->--split--+->--encode---->+--join-->reformat
# \>--identity--/ # \>--identity--/
self.pipe = rcompose( self.pipe = rcompose(
@ -64,7 +61,6 @@ class Pipeline:
pairwise_apply(classify, represent, identity), # ... apply functions to the streams pairwise pairwise_apply(classify, represent, identity), # ... apply functions to the streams pairwise
join, # ... the streams by zipping join, # ... the streams by zipping
reformat, # ... the items reformat, # ... the items
filter_duplicates, # ... filter out duplicate images
) )
def __call__(self, pdf: bytes, page_range: range = None): def __call__(self, pdf: bytes, page_range: range = None):
@ -74,32 +70,3 @@ class Pipeline:
unit=" images", unit=" images",
disable=not self.verbose, disable=not self.verbose,
) )
def filter_duplicates(metadata: Iterable[dict[str, Any]]) -> Iterable[dict[str, Any]]:
"""Filter out duplicate images from the `position` (image coordinates) and `page`, preferring the one with
`allPassed` set to True.
See RED-10765 (RM-241): Removed redactions reappear for why this is necessary.
"""
keep = dict()
for image_meta in metadata:
key: tuple[int, int, int, int, int] = (
image_meta["position"]["x1"],
image_meta["position"]["x2"],
image_meta["position"]["y1"],
image_meta["position"]["y2"],
image_meta["position"]["pageNumber"],
)
if key in keep:
logger.warning(
f"Duplicate image found: x1={key[0]}, x2={key[1]}, y1={key[2]}, y2={key[3]}, pageNumber={key[4]}"
)
if image_meta["filters"]["allPassed"]:
logger.warning("Setting the image with allPassed flag set to True")
keep[key] = image_meta
else:
logger.warning("Keeping the previous image since the current image has allPassed flag set to False")
else:
keep[key] = image_meta
yield from keep.values()

View File

@ -1,8 +1,13 @@
import json
import math import math
from dynaconf import Dynaconf import os
from functools import lru_cache
from operator import itemgetter from operator import itemgetter
from funcy import first
from image_prediction.config import CONFIG from image_prediction.config import CONFIG
from image_prediction.exceptions import ParsingError
from image_prediction.transformer.transformer import Transformer from image_prediction.transformer.transformer import Transformer
from image_prediction.utils import get_logger from image_prediction.utils import get_logger
@ -27,22 +32,21 @@ def build_image_info(data: dict) -> dict:
geometric_quotient = round(compute_geometric_quotient(page_width, page_height, x2, x1, y2, y1), 4) geometric_quotient = round(compute_geometric_quotient(page_width, page_height, x2, x1, y2, y1), 4)
min_image_to_page_quotient_breached = bool( min_image_to_page_quotient_breached = bool(
geometric_quotient < get_class_specific_filter_value(label, CONFIG, "image_to_page_quotient", "min") geometric_quotient < get_class_specific_min_image_to_page_quotient(label)
) )
max_image_to_page_quotient_breached = bool( max_image_to_page_quotient_breached = bool(
geometric_quotient > get_class_specific_filter_value(label, CONFIG, "image_to_page_quotient", "max") geometric_quotient > get_class_specific_max_image_to_page_quotient(label)
) )
min_image_width_to_height_quotient_breached = bool( min_image_width_to_height_quotient_breached = bool(
width / height < get_class_specific_filter_value(label, CONFIG, "image_width_to_height_quotient", "min") width / height < get_class_specific_min_image_width_to_height_quotient(label)
) )
max_image_width_to_height_quotient_breached = bool( max_image_width_to_height_quotient_breached = bool(
width / height > get_class_specific_filter_value(label, CONFIG, "image_width_to_height_quotient", "max") width / height > get_class_specific_max_image_width_to_height_quotient(label)
) )
min_confidence_breached = bool( min_confidence_breached = bool(
max(classification["probabilities"].values()) max(classification["probabilities"].values()) < get_class_specific_min_classification_confidence(label)
< get_class_specific_filter_value(label, CONFIG, "confidence", "min")
) )
image_info = { image_info = {
@ -86,15 +90,65 @@ def compute_geometric_quotient(page_width, page_height, x2, x1, y2, y1):
return image_area_sqrt / page_area_sqrt return image_area_sqrt / page_area_sqrt
def get_class_specific_filter_value(label: str, settings: Dynaconf, filter_type: str, bound: str = None): def get_class_specific_min_image_to_page_quotient(label, table=None):
try: return get_class_specific_value(
value = ( "REL_IMAGE_SIZE", label, "min", CONFIG.filters.image_to_page_quotient.min, table=table
settings.filters.overrides[label][filter_type][bound]
if bound
else settings.filters.overrides[label][filter_type]
) )
logger.warning(f"Using {label=} specific {bound=} {filter_type=} {value=}.")
except KeyError:
value = settings.filters[filter_type][bound]
def get_class_specific_max_image_to_page_quotient(label, table=None):
return get_class_specific_value(
"REL_IMAGE_SIZE", label, "max", CONFIG.filters.image_to_page_quotient.max, table=table
)
def get_class_specific_min_image_width_to_height_quotient(label, table=None):
return get_class_specific_value(
"IMAGE_FORMAT", label, "min", CONFIG.filters.image_width_to_height_quotient.min, table=table
)
def get_class_specific_max_image_width_to_height_quotient(label, table=None):
return get_class_specific_value(
"IMAGE_FORMAT", label, "max", CONFIG.filters.image_width_to_height_quotient.max, table=table
)
def get_class_specific_min_classification_confidence(label, table=None):
return get_class_specific_value("CONFIDENCE", label, "min", CONFIG.filters.min_confidence, table=table)
def get_class_specific_value(prefix, label, bound, fallback_value, table=None):
def fallback():
return fallback_value
def success():
threshold_map = parse_env_var(prefix, table=table) or {}
value = threshold_map.get(label, {}).get(bound)
if value:
logger.debug(f"Using class '{label}' specific {bound} {prefix.lower().replace('_', '-')} value.")
return value return value
assert bound in ["min", "max"]
return success() or fallback()
@lru_cache(maxsize=None)
def parse_env_var(prefix, table=None):
table = table or os.environ
head = first(filter(lambda s: s == prefix, table))
if head:
try:
return parse_env_var_value(table[head])
except ParsingError as err:
logger.warning(err)
else:
return None
def parse_env_var_value(env_var_value):
try:
return json.loads(env_var_value)
except Exception as err:
raise ParsingError(f"Failed to parse {env_var_value}") from err

View File

@ -56,8 +56,7 @@ def annotate_image(doc, image_info):
def init(): def init():
PDFNet.Initialize( PDFNet.Initialize(
# "Knecon AG(en.knecon.swiss):OEM:DDA-R::WL+:AMS(20211029):BECC974307DAB4F34B513BC9B2531B24496F6FCB83CD8AC574358A959730B622FABEF5C7" "Knecon AG(en.knecon.swiss):OEM:DDA-R::WL+:AMS(20211029):BECC974307DAB4F34B513BC9B2531B24496F6FCB83CD8AC574358A959730B622FABEF5C7"
"Knecon AG:OEM:DDA-R::WL+:AMS(20270129):EA5FDFB23C7F36B9C2AE606F4F0D9197DE1FB649119F9730B622FABEF5C7"
) )

View File

@ -9,7 +9,8 @@ from image_prediction.pipeline import load_pipeline
from image_prediction.utils.banner import load_banner from image_prediction.utils.banner import load_banner
from image_prediction.utils.process_wrapping import wrap_in_process from image_prediction.utils.process_wrapping import wrap_in_process
logger.reconfigure(sink=stdout, level=CONFIG.logging.level) logger.remove()
logger.add(sink=stdout, level=CONFIG.logging.level)
# A component of the processing pipeline (probably tensorflow) does not release allocated memory (see RED-4206). # A component of the processing pipeline (probably tensorflow) does not release allocated memory (see RED-4206).
@ -18,7 +19,7 @@ logger.reconfigure(sink=stdout, level=CONFIG.logging.level)
# FIXME: Find more fine-grained solution or if the problem occurs persistently for python services, # FIXME: Find more fine-grained solution or if the problem occurs persistently for python services,
@wrap_in_process @wrap_in_process
def process_data(data: bytes, _message: dict) -> list: def process_data(data: bytes, _message: dict) -> list:
pipeline = load_pipeline(verbose=CONFIG.service.verbose, batch_size=CONFIG.service.batch_size, tolerance=CONFIG.service.image_stiching_tolerance) pipeline = load_pipeline(verbose=CONFIG.service.verbose, batch_size=CONFIG.service.batch_size)
return list(pipeline(data)) return list(pipeline(data))

View File

@ -1,5 +1,5 @@
outs: outs:
- md5: 08bf8a63f04b3f19f859008556699708.dir - md5: 4b0fec291ce0661b3efbbd8b80f4f514.dir
size: 7979836 size: 107332
nfiles: 7 nfiles: 4
path: data path: data

View File

@ -1,21 +0,0 @@
from pathlib import Path
from funcy import first
from image_prediction.config import CONFIG
from image_prediction.pipeline import load_pipeline
def test_image_classification_does_not_regress():
"""See RED-9948: the predictions unexpectedly changed for some images. In the end the issue is the tensorflow
version. We ensure that the prediction of the image with the hash FA30F080F0C031CE17E8CF237 is inconclusive,
and that the flag all_passed is false."""
pdf_path = Path(__file__).parents[1] / "data" / "RED-9948" / "SYNGENTA_EFSA_sanitisation_GFL_v2.pdf"
pdf_bytes = pdf_path.read_bytes()
pipeline = load_pipeline(verbose=True, batch_size=CONFIG.service.batch_size)
predictions = list(pipeline(pdf_bytes))
predictions = first([x for x in predictions if x["representation"] == "FA30F080F0C031CE17E8CF237"])
assert predictions["filters"]["allPassed"] is False
assert predictions["filters"]["probability"]["unconfident"] is True

View File

@ -1,35 +0,0 @@
from pathlib import Path
from image_prediction.config import CONFIG
from image_prediction.pipeline import load_pipeline
def test_all_duplicate_images_are_filtered():
"""See RED-10765 (RM-241): Removed redactions reappear."""
pdf_path = (
Path(__file__).parents[1]
/ "data"
/ "RED-10765"
/ "RM-241-461c90d6d6dc0416ad5f0b05feef4dfc.UNTOUCHED_shortened.pdf"
)
pdf_bytes = pdf_path.read_bytes()
pipeline = load_pipeline(verbose=True, batch_size=CONFIG.service.batch_size)
predictions = list(pipeline(pdf_bytes))
seen = set()
for prediction in predictions:
key = (
prediction["position"]["x1"],
prediction["position"]["x2"],
prediction["position"]["y1"],
prediction["position"]["y2"],
prediction["position"]["pageNumber"],
)
assert key not in seen, f"Duplicate found: {key}"
seen.add(key)
all_passed = sum(1 for prediction in predictions if prediction["filters"]["allPassed"])
assert all_passed == 1, f"Expected 1 image with allPassed flag set to True, but got {all_passed}"
assert len(predictions) == 177, f"Expected 177 images, but got {len(predictions)}"

View File

@ -1,18 +0,0 @@
from pathlib import Path
from image_prediction.encoder.encoders.hash_encoder import HashEncoder
from image_prediction.image_extractor.extractors.parsable import ParsablePDFImageExtractor
def test_all_hashes_have_length_of_twentyfive():
"""See RED-3814: all hashes should have 25 characters."""
pdf_path = Path(__file__).parents[1] / "data" / "RED-3814" / "similarImages2.pdf"
pdf_bytes = pdf_path.read_bytes()
image_extractor = ParsablePDFImageExtractor()
image_metadata_pairs = list(image_extractor.extract(pdf_bytes))
images = [image for image, _ in image_metadata_pairs]
hash_encoder = HashEncoder()
hashes = list(hash_encoder.encode(images))
assert all(len(h) == 25 for h in hashes)

View File

@ -6,8 +6,7 @@ import pytest
from PIL.Image import Image from PIL.Image import Image
from funcy import compose, first from funcy import compose, first
from image_prediction.encoder.encoders.hash_encoder import HashEncoder from image_prediction.encoder.encoders.hash_encoder import HashEncoder, hash_image
from image_prediction.encoder.encoders.hash_encoder import hash_image
from image_prediction.utils.generic import lift from image_prediction.utils.generic import lift

View File

@ -1,7 +1,15 @@
import pytest import json
from image_prediction.config import CONFIG import pytest
from image_prediction.transformer.transformers.response import get_class_specific_filter_value from frozendict import frozendict
from image_prediction.transformer.transformers.response import (
get_class_specific_min_image_to_page_quotient,
get_class_specific_max_image_to_page_quotient,
get_class_specific_max_image_width_to_height_quotient,
get_class_specific_min_image_width_to_height_quotient,
get_class_specific_min_classification_confidence,
)
@pytest.fixture @pytest.fixture
@ -9,9 +17,20 @@ def label():
return "signature" return "signature"
def test_read_environment_vars_for_thresholds(label): @pytest.fixture
assert get_class_specific_filter_value(label, CONFIG, "image_to_page_quotient", "min") == 0.05 def page_quotient_threshold_map(label):
assert get_class_specific_filter_value(label, CONFIG, "image_to_page_quotient", "max") == 0.4 return frozendict(
assert get_class_specific_filter_value(label, CONFIG, "image_width_to_height_quotient", "min") == 0.1 {
assert get_class_specific_filter_value(label, CONFIG, "image_width_to_height_quotient", "max") == 10 "REL_IMAGE_SIZE": json.dumps({label: {"min": 0.1, "max": 0.2}}),
assert get_class_specific_filter_value(label, CONFIG, "confidence", "min") == 0.5 "IMAGE_FORMAT": json.dumps({label: {"min": 0.5, "max": 0.4}}),
"CONFIDENCE": json.dumps({label: {"min": 0.8}}),
}
)
def test_read_environment_vars_for_thresholds(page_quotient_threshold_map, label):
assert get_class_specific_min_image_to_page_quotient(label, table=page_quotient_threshold_map) == 0.1
assert get_class_specific_max_image_to_page_quotient(label, table=page_quotient_threshold_map) == 0.2
assert get_class_specific_min_image_width_to_height_quotient(label, table=page_quotient_threshold_map) == 0.5
assert get_class_specific_max_image_width_to_height_quotient(label, table=page_quotient_threshold_map) == 0.4
assert get_class_specific_min_classification_confidence(label, table=page_quotient_threshold_map) == 0.8