File size: 9,730 Bytes
4918630
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
"""Clinical image preprocessor for medical documents and prescriptions.

Implements a robust preprocessing pipeline using OpenCV (with Pillow fallback):
1. RGB normalization
2. Grayscale conversion
3. Bilateral denoising (edge-preserving β€” preserves ink strokes)
4. CLAHE contrast enhancement
5. Morphological opening (gentle noise removal)
6. Min/max normalization
Optional:
7. Deskew (not implemented in PASS 1 β€” flagged for PASS 2)

Design principles from reference repository (sanketnaik99/doctors-prescription):
- Grayscale + CLAHE + bilateral filter + morphological operations improve OCR
  on faded, low-contrast, or paper-grain-noisy prescription images.
- Original bytes are NEVER modified. Returns new bytes.
- Processed bytes are temporary and must not be persisted.

IMPORTANT: This preprocessor does NOT call any OCR or AI model.
It is independently testable with no external dependencies beyond OpenCV/Pillow.
"""

from __future__ import annotations

import io
import logging
from dataclasses import dataclass

import numpy as np
from PIL import Image, ImageEnhance

log = logging.getLogger(__name__)

try:
    import cv2
    _OPENCV_AVAILABLE = True
except ImportError:
    cv2 = None  # type: ignore[assignment]
    _OPENCV_AVAILABLE = False


@dataclass(frozen=True)
class PreprocessingResult:
    """Result of preprocessing a single image."""

    processed_bytes: bytes
    mime_type: str                         # Always "image/png" after processing
    operations_applied: tuple[str, ...]    # Ordered list of operations performed
    estimated_quality_improvement: float  # Estimated improvement (0.0 = no change, 1.0 = max)
    original_size_bytes: int
    processed_size_bytes: int


class ClinicalImagePreprocessor:
    """Preprocesses clinical document images for downstream OCR.

    Usage:
        preprocessor = ClinicalImagePreprocessor()
        result = preprocessor.preprocess_image(raw_bytes)
        # Use result.processed_bytes for OCR

    All operations are applied to an in-memory copy. Original bytes untouched.
    """

    def __init__(
        self,
        *,
        contrast_boost: float = 1.4,
        apply_clahe: bool = True,
        apply_morphology: bool = True,
        clahe_clip_limit: float = 2.0,
        clahe_grid_size: tuple[int, int] = (8, 8),
        bilateral_d: int = 5,
        bilateral_sigma_color: float = 50.0,
        bilateral_sigma_space: float = 50.0,
    ) -> None:
        self._contrast_boost = contrast_boost
        self._apply_clahe = apply_clahe
        self._apply_morphology = apply_morphology
        self._clahe_clip_limit = clahe_clip_limit
        self._clahe_grid_size = clahe_grid_size
        self._bilateral_d = bilateral_d
        self._bilateral_sigma_color = bilateral_sigma_color
        self._bilateral_sigma_space = bilateral_sigma_space

    def preprocess_image(self, image_bytes: bytes) -> PreprocessingResult:
        """Preprocess a clinical document image.

        Primary path: OpenCV (grayscale β†’ bilateral β†’ CLAHE β†’ morph β†’ normalize)
        Fallback: Pillow contrast enhancement only.

        Args:
            image_bytes: Raw image bytes (PNG, JPEG, or WebP).

        Returns:
            PreprocessingResult with processed_bytes in PNG format.
        """
        operations: list[str] = []
        original_size = len(image_bytes)

        # Open with Pillow first (handles multiple formats)
        try:
            pil_img = Image.open(io.BytesIO(image_bytes))
            if pil_img.mode != "RGB":
                pil_img = pil_img.convert("RGB")
                operations.append("convert_to_rgb")
        except Exception as exc:
            log.warning("Pillow cannot open image: %s", exc)
            return PreprocessingResult(
                processed_bytes=image_bytes,
                mime_type="image/png",
                operations_applied=("error_passthrough",),
                estimated_quality_improvement=0.0,
                original_size_bytes=original_size,
                processed_size_bytes=original_size,
            )

        np_rgb = np.array(pil_img)

        # Estimate pre-processing contrast as baseline
        pre_std = float(np.std(np.mean(np_rgb, axis=2)))

        if _OPENCV_AVAILABLE and cv2 is not None:
            result = self._opencv_pipeline(np_rgb, operations)
            if result is not None:
                post_std = float(np.std(result)) if result.ndim == 2 else float(
                    np.std(np.mean(result, axis=2))
                )
                improvement = max(0.0, min(1.0, (post_std - pre_std) / max(1.0, pre_std)))
                # Encode back to PNG
                try:
                    if result.ndim == 2:
                        # Grayscale β†’ back to RGB for downstream model compatibility
                        rgb_out = cv2.cvtColor(result, cv2.COLOR_GRAY2RGB)
                    else:
                        rgb_out = result
                    success, encoded = cv2.imencode(".png", rgb_out)
                    if success:
                        processed_bytes = encoded.tobytes()
                        return PreprocessingResult(
                            processed_bytes=processed_bytes,
                            mime_type="image/png",
                            operations_applied=tuple(operations),
                            estimated_quality_improvement=round(improvement, 3),
                            original_size_bytes=original_size,
                            processed_size_bytes=len(processed_bytes),
                        )
                except Exception as enc_err:
                    log.warning("OpenCV encode failed: %s", enc_err)

        # Pillow fallback
        return self._pillow_fallback(pil_img, image_bytes, operations, pre_std, original_size)

    def _opencv_pipeline(
        self, np_rgb: np.ndarray, operations: list[str]
    ) -> np.ndarray | None:
        """Run the full OpenCV preprocessing pipeline.

        Returns the processed image array, or None on failure.
        """
        try:
            # BGR for OpenCV
            cv_bgr = cv2.cvtColor(np_rgb, cv2.COLOR_RGB2BGR)  # type: ignore[union-attr]

            # Grayscale
            gray = cv2.cvtColor(cv_bgr, cv2.COLOR_BGR2GRAY)  # type: ignore[union-attr]
            operations.append("opencv_grayscale")

            # Bilateral denoising β€” preserves ink edges, smooths paper grain
            denoised = cv2.bilateralFilter(  # type: ignore[union-attr]
                gray,
                d=self._bilateral_d,
                sigmaColor=self._bilateral_sigma_color,
                sigmaSpace=self._bilateral_sigma_space,
            )
            operations.append("bilateral_denoise")

            # CLAHE β€” Contrast Limited Adaptive Histogram Equalization
            if self._apply_clahe:
                clahe = cv2.createCLAHE(  # type: ignore[union-attr]
                    clipLimit=self._clahe_clip_limit,
                    tileGridSize=self._clahe_grid_size,
                )
                enhanced = clahe.apply(denoised)
                operations.append("clahe_contrast_enhancement")
            else:
                enhanced = denoised

            # Morphological opening β€” gentle noise removal without eroding strokes
            if self._apply_morphology:
                kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (2, 2))  # type: ignore[union-attr]
                morphed = cv2.morphologyEx(enhanced, cv2.MORPH_OPEN, kernel)  # type: ignore[union-attr]
                operations.append("morphological_open_2x2")
            else:
                morphed = enhanced

            # Normalize to full [0, 255] range
            normalized = cv2.normalize(morphed, None, 0, 255, cv2.NORM_MINMAX)  # type: ignore[union-attr]
            operations.append("minmax_normalize")

            return normalized  # type: ignore[return-value]

        except Exception as exc:
            log.warning("OpenCV pipeline failed: %s", exc)
            return None

    def _pillow_fallback(
        self,
        pil_img: Image.Image,
        original_bytes: bytes,
        operations: list[str],
        pre_std: float,
        original_size: int,
    ) -> PreprocessingResult:
        """Pillow-based fallback: simple contrast enhancement."""
        try:
            enhancer = ImageEnhance.Contrast(pil_img)
            enhanced = enhancer.enhance(self._contrast_boost)
            operations.append(f"pillow_contrast_{self._contrast_boost}x")

            buf = io.BytesIO()
            enhanced.save(buf, format="PNG")
            processed_bytes = buf.getvalue()

            np_enhanced = np.array(enhanced.convert("L"))
            post_std = float(np.std(np_enhanced))
            improvement = max(0.0, min(1.0, (post_std - pre_std) / max(1.0, pre_std)))

            return PreprocessingResult(
                processed_bytes=processed_bytes,
                mime_type="image/png",
                operations_applied=tuple(operations),
                estimated_quality_improvement=round(improvement, 3),
                original_size_bytes=original_size,
                processed_size_bytes=len(processed_bytes),
            )
        except Exception as pil_err:
            log.warning("Pillow fallback failed: %s", pil_err)
            return PreprocessingResult(
                processed_bytes=original_bytes,
                mime_type="image/png",
                operations_applied=("error_passthrough",),
                estimated_quality_improvement=0.0,
                original_size_bytes=original_size,
                processed_size_bytes=original_size,
            )


__all__ = ["ClinicalImagePreprocessor", "PreprocessingResult"]