File size: 9,240 Bytes
7d835bb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a8dceee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7d835bb
 
 
 
 
 
a8dceee
 
7d835bb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a8dceee
 
 
 
7d835bb
 
 
 
a8dceee
 
7d835bb
 
 
 
 
 
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
import cv2
import numpy as np
from typing import Union


class Preprocessor:
    """Stage 0: Image preprocessing for BOM engineering drawings."""

    def load_image(self, path: str) -> np.ndarray:
        """Load image from path and return as grayscale uint8 array.

        Args:
            path: File path to image (PNG, JPG, TIFF supported).

        Returns:
            Grayscale numpy array (uint8, 0-255).

        Raises:
            ValueError: If image cannot be read.
        """
        img = cv2.imread(path, cv2.IMREAD_GRAYSCALE)
        if img is None:
            raise ValueError(f"Cannot read image: {path}")
        return img

    def binarize(self, img: np.ndarray, method: str = "adaptive") -> np.ndarray:
        """Binarize grayscale image.

        Args:
            img: Grayscale numpy array.
            method: "adaptive" | "otsu" | "none"

        Returns:
            Binary image with pixel values 0 or 255.
        """
        if method == "adaptive":
            return cv2.adaptiveThreshold(
                img, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
                cv2.THRESH_BINARY, blockSize=15, C=4
            )
        elif method == "otsu":
            _, binary = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
            return binary
        elif method == "none":
            if len(img.shape) == 3:
                return cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
            return img
        else:
            raise ValueError(f"Unknown binarize method: {method}. Use 'adaptive', 'otsu', or 'none'.")

    def denoise(self, img: np.ndarray, kernel_size: int = 2) -> np.ndarray:
        """Apply morphological closing to fill broken thin lines.

        Args:
            img: Binary or grayscale image.
            kernel_size: Size of the morphological kernel (keep small to preserve thin lines).

        Returns:
            Denoised image.
        """
        kernel = cv2.getStructuringElement(
            cv2.MORPH_ELLIPSE, (kernel_size, kernel_size)
        )
        return cv2.morphologyEx(img, cv2.MORPH_CLOSE, kernel)

    def resize_if_needed(self, img: np.ndarray, max_dim: int = 4096) -> np.ndarray:
        """Resize image if its largest dimension exceeds max_dim, preserving aspect ratio.

        Args:
            img: Input image.
            max_dim: Maximum allowed dimension in pixels.

        Returns:
            Resized image (or original if already within bounds).
        """
        h, w = img.shape[:2]
        largest = max(h, w)
        if largest <= max_dim:
            return img
        scale = max_dim / largest
        new_w = int(w * scale)
        new_h = int(h * scale)
        return cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_AREA)

    def suppress_text_noise(self, img: np.ndarray, max_area: int = 180) -> np.ndarray:
        """Remove small isolated strokes (text labels) from a binary image.

        Uses connected-component analysis to erase blobs that are small and
        elongated — the typical signature of text characters — while preserving
        larger, rounder component symbols.

        Args:
            img: Binary image (white background, black strokes).
            max_area: Components with pixel area below this threshold AND
                      aspect ratio > 1.3 are treated as text and erased.

        Returns:
            Binary image with text-like blobs replaced by white.
        """
        inv = cv2.bitwise_not(img)
        n_labels, labels, stats, _ = cv2.connectedComponentsWithStats(inv, connectivity=8)
        result = img.copy()
        for lbl in range(1, n_labels):
            area = int(stats[lbl, cv2.CC_STAT_AREA])
            w = int(stats[lbl, cv2.CC_STAT_WIDTH])
            h = int(stats[lbl, cv2.CC_STAT_HEIGHT])
            if w == 0 or h == 0:
                continue
            aspect = max(w, h) / min(w, h)
            if area < max_area and aspect > 1.3:
                result[labels == lbl] = 255
        return result

    def dilate_strokes(self, img: np.ndarray, kernel_size: int = 5) -> np.ndarray:
        """Thicken black strokes in a binary image by eroding (strokes are black on white bg).

        Useful when template uses thin stylized lines but drawing uses bold strokes.

        Args:
            img: Binary image (white background, black strokes).
            kernel_size: Size of dilation kernel.

        Returns:
            Image with thickened strokes.
        """
        kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel_size, kernel_size))
        return cv2.erode(img, kernel)

    def clahe_enhance(
        self, img: np.ndarray, clip_limit: float = 2.0, tile_size: int = 8
    ) -> np.ndarray:
        """Apply CLAHE (Contrast Limited Adaptive Histogram Equalization).

        Improves local contrast in drawings with uneven line density — faint
        strokes that are washed out globally become visible after CLAHE.
        Applied to grayscale images BEFORE binarization.

        Args:
            img: Grayscale uint8 image.
            clip_limit: Threshold for contrast limiting (higher = stronger).
            tile_size: Grid size in pixels for local histogram computation.

        Returns:
            Contrast-enhanced grayscale image.
        """
        clahe = cv2.createCLAHE(
            clipLimit=clip_limit, tileGridSize=(tile_size, tile_size)
        )
        return clahe.apply(img.astype(np.uint8))

    def normalize_strokes(
        self, img: np.ndarray, target_width: int = 2
    ) -> np.ndarray:
        """Normalize stroke width via thinning + uniform dilation.

        Engineering drawings may scan at different DPIs, producing thick or
        thin lines. This normalizes all strokes to `target_width` pixels:
          1. Morphological thinning (iterative erosion to approximate skeleton)
          2. Dilation to the desired target width

        This makes NCC matching invariant to line-width variation between the
        template and the drawing, which is a common source of false negatives.

        Args:
            img: Binary image (white background, black strokes).
            target_width: Desired uniform stroke width in pixels.

        Returns:
            Binary image with normalized stroke width.
        """
        inv = cv2.bitwise_not(img)

        # Iterative thinning: erode until no more pixels can be removed.
        # Stop after 20 iterations to bound runtime.
        kernel = cv2.getStructuringElement(cv2.MORPH_CROSS, (3, 3))
        prev = np.zeros_like(inv)
        for _ in range(20):
            eroded = cv2.erode(inv, kernel)
            temp = cv2.dilate(eroded, kernel)
            diff = cv2.subtract(inv, temp)
            skeleton = cv2.bitwise_or(prev, diff)
            inv = eroded.copy()
            prev = skeleton.copy()
            if cv2.countNonZero(inv) == 0:
                break

        # Re-dilate skeleton to target width
        if target_width > 1:
            kw = target_width * 2 - 1
            dk = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kw, kw))
            skeleton = cv2.dilate(skeleton, dk)

        return cv2.bitwise_not(skeleton)

    def preprocess(
        self,
        img_or_path: Union[str, np.ndarray],
        binarize_method: str = "adaptive",
        denoise: bool = True,
        dilate_strokes: int = 0,
        clahe: bool = False,
        normalize_stroke_width: int = 0,
    ) -> dict:
        """Full preprocessing pipeline.

        Args:
            img_or_path: File path string or numpy array.
            binarize_method: Binarization method passed to self.binarize().
            denoise: Whether to apply morphological denoising.

        Returns:
            Dict with keys:
                "original": original grayscale numpy array
                "processed": preprocessed numpy array
                "scale_factor": downscale ratio applied (1.0 if no resize)
        """
        if isinstance(img_or_path, str):
            original = self.load_image(img_or_path)
        elif isinstance(img_or_path, np.ndarray):
            if len(img_or_path.shape) == 3:
                original = cv2.cvtColor(img_or_path, cv2.COLOR_RGB2GRAY)
            else:
                original = img_or_path.copy()
        else:
            raise ValueError("img_or_path must be a file path string or numpy array.")

        resized = self.resize_if_needed(original)
        h_orig, w_orig = original.shape[:2]
        h_res, w_res = resized.shape[:2]
        scale_factor = h_res / h_orig if h_orig > 0 else 1.0

        # CLAHE before binarization: improves faint-stroke visibility
        to_binarize = self.clahe_enhance(resized) if clahe else resized

        processed = self.binarize(to_binarize, method=binarize_method)
        if denoise:
            processed = self.denoise(processed)
        if dilate_strokes > 0:
            processed = self.dilate_strokes(processed, kernel_size=dilate_strokes)
        if normalize_stroke_width > 0:
            processed = self.normalize_strokes(processed, target_width=normalize_stroke_width)

        return {
            "original": original,
            "processed": processed,
            "scale_factor": scale_factor,
        }