Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.00% covered (success)
97.00%
97 / 100
87.50% covered (warning)
87.50%
7 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
BasicOcr
97.00% covered (success)
97.00%
97 / 100
87.50% covered (warning)
87.50%
7 / 8
46
0.00% covered (danger)
0.00%
0 / 1
 trainWith
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 readImages
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
15
 readLabels
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
11
 kNearest
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 getDistances
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 imagesToMNIST
90.91% covered (success)
90.91%
30 / 33
0.00% covered (danger)
0.00%
0 / 1
9.06
 labelsToMNIST
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 matchImage
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Jingga
4 *
5 * PHP Version 8.1
6 *
7 * @package   phpOMS\Ai\Ocr
8 * @copyright Dennis Eichhorn
9 * @license   OMS License 2.0
10 * @version   1.0.0
11 * @link      https://jingga.app
12 */
13declare(strict_types=1);
14
15namespace phpOMS\Ai\Ocr;
16
17use phpOMS\Math\Topology\MetricsND;
18use phpOMS\System\File\PathException;
19
20/**
21 * Basic OCR implementation for MNIST data
22 *
23 * @package phpOMS\Ai\Ocr
24 * @license OMS License 2.0
25 * @link    https://jingga.app
26 * @since   1.0.0
27 */
28final class BasicOcr
29{
30    /**
31     * Dataset on which the OCR is trained on.
32     *
33     * The data needs to be MNIST data.
34     *
35     * @var array
36     * @since 1.0.0
37     */
38    private array $Xtrain = [];
39
40    /**
41     * Resultset on which the OCR is trained on.
42     *
43     * These are the actual values for the Xtrain data and must therefore have the same dimension.
44     *
45     * The labels need to be MNIST labels.
46     *
47     * @var array
48     * @since 1.0.0
49     */
50    private array $ytrain = [];
51
52    /**
53     * Train OCR with data and result/labels
54     *
55     * @param string $dataPath  Impage path to read
56     * @param string $labelPath Label path to read
57     * @param int    $limit     Limit (0 = unlimited)
58     *
59     * @return void
60     *
61     * @since 1.0.0
62     */
63    public function trainWith(string $dataPath, string $labelPath, int $limit = 0) : void
64    {
65        $Xtrain = $this->readImages($dataPath, $limit);
66        $ytrain = $this->readLabels($labelPath, $limit);
67
68        $this->Xtrain = \array_merge($this->Xtrain, $Xtrain);
69        $this->ytrain = \array_merge($this->ytrain, $ytrain);
70    }
71
72    /**
73     * Read image from path
74     *
75     * @param string $path  Image to read
76     * @param int    $limit Limit
77     *
78     * @return array
79     *
80     * @throws PathException
81     *
82     * @since 1.0.0
83     */
84    private function readImages(string $path, int $limit = 0) : array
85    {
86        if (!\is_file($path)) {
87            throw new PathException($path);
88        }
89
90        $fp = \fopen($path, 'r');
91        if ($fp === false) {
92            throw new PathException($path); // @codeCoverageIgnore
93        }
94
95        if (($read = \fread($fp, 4)) === false || ($unpack = \unpack('N', $read)) === false) {
96            return []; // @codeCoverageIgnore
97        }
98
99        // $magicNumber = $unpack[1];
100        // 2051 === image data (should always be this)
101        // 2049 === label data
102
103        if (($read = \fread($fp, 4)) === false || ($unpack = \unpack('N', $read)) === false) {
104            return []; // @codeCoverageIgnore
105        }
106        $numberOfImages = $unpack[1];
107
108        if ($limit > 0) {
109            $numberOfImages = \min($numberOfImages, $limit);
110        }
111
112        if (($read = \fread($fp, 4)) === false || ($unpack = \unpack('N', $read)) === false) {
113            return []; // @codeCoverageIgnore
114        }
115
116        /** @var int<0, max> $numberOfRows */
117        $numberOfRows = (int) $unpack[1];
118
119        if (($read = \fread($fp, 4)) === false || ($unpack = \unpack('N', $read)) === false) {
120            return []; // @codeCoverageIgnore
121        }
122
123        /** @var int<0, max> $numberOfColumns */
124        $numberOfColumns = (int) $unpack[1];
125
126        $images = [];
127        for ($i = 0; $i < $numberOfImages; ++$i) {
128            if (($read = \fread($fp, $numberOfRows * $numberOfColumns)) === false
129                || ($unpack = \unpack('C*', $read)) === false
130            ) {
131                return []; // @codeCoverageIgnore
132            }
133
134            $images[] = \array_values($unpack);
135        }
136
137        \fclose($fp);
138
139        return $images;
140    }
141
142    /**
143     * Read labels from from path
144     *
145     * @param string $path  Labels path
146     * @param int    $limit Limit
147     *
148     * @return array
149     *
150     * @throws PathException
151     *
152     * @since 1.0.0
153     */
154    private function readLabels(string $path, int $limit = 0) : array
155    {
156        if (!\is_file($path)) {
157            throw new PathException($path);
158        }
159
160        $fp = \fopen($path, 'r');
161        if ($fp === false) {
162            throw new PathException($path); // @codeCoverageIgnore
163        }
164
165        if (($read = \fread($fp, 4)) === false || ($unpack = \unpack('N', $read)) === false) {
166            return []; // @codeCoverageIgnore
167        }
168
169        // $magicNumber = $unpack[1];
170        // 2051 === image data
171        // 2049 === label data (should always be this)
172
173        if (($read = \fread($fp, 4)) === false || ($unpack = \unpack('N', $read)) === false) {
174            return []; // @codeCoverageIgnore
175        }
176        $numberOfLabels = $unpack[1];
177
178        if ($limit > 0) {
179            $numberOfLabels = \min($numberOfLabels, $limit);
180        }
181
182        $labels = [];
183        for ($i = 0; $i < $numberOfLabels; ++$i) {
184            if (($read = \fread($fp, 1)) === false || ($unpack = \unpack('C', $read)) === false) {
185                return []; // @codeCoverageIgnore
186            }
187            $labels[] = $unpack[1];
188        }
189
190        \fclose($fp);
191
192        return $labels;
193    }
194
195    /**
196     * Find the k-nearest matches for test data
197     *
198     * @param array $Xtrain Image data used for training
199     * @param array $ytrain Labels associated with the trained data
200     * @param array $Xtest  Image data from the image to categorize
201     * @param int   $k      Amount of best fits that should be found
202     */
203    private function kNearest(array $Xtrain, array $ytrain, array $Xtest, int $k = 3) : array
204    {
205        $predictedLabels = [];
206        foreach ($Xtest as $sample) {
207            $distances = $this->getDistances($Xtrain, $sample);
208            \asort($distances);
209
210            $keys = \array_keys($distances);
211
212            $candidateLabels = [];
213            for ($i = 0; $i < $k; ++$i) {
214                $candidateLabels[] = $ytrain[$keys[$i]];
215            }
216
217            // find best match
218            $countedCandidates = \array_count_values($candidateLabels);
219
220            foreach ($candidateLabels as $i => $label) {
221                $predictedLabels[] = [
222                    'label' => $label,
223                    'prob'  => $countedCandidates[$label] / $k,
224                ];
225            }
226        }
227
228        return $predictedLabels;
229    }
230
231    /**
232     * Fitting method in order to see how similar two datasets are.
233     *
234     * @param array $Xtrain Image data used for training
235     * @param array $sample Image data to compare against
236     *
237     * @return array
238     *
239     * @since 1.0.0
240     */
241    private function getDistances(array $Xtrain, array $sample) : array
242    {
243        $dist = [];
244        foreach ($Xtrain as $train) {
245            $dist[] = MetricsND::euclidean($train, $sample);
246        }
247
248        return $dist;
249    }
250
251    /**
252     * Create MNIST file from images
253     *
254     * @param string[] $images     Images
255     * @param string   $out        Output file
256     * @param int      $resolution Resolution of the iomages
257     *
258     * @return void
259     *
260     * @since 1.0.0
261     */
262    public static function imagesToMNIST(array $images, string $out, int $resolution) : void
263    {
264        $out = \fopen($out, 'wb');
265        if ($out === false) {
266            return; // @codeCoverageIgnore
267        }
268
269        \fwrite($out, \pack('N', 2051));
270        \fwrite($out, \pack('N', \count($images)));
271        \fwrite($out, \pack('N', $resolution));
272        \fwrite($out, \pack('N', $resolution));
273
274        $size = $resolution * $resolution;
275
276        foreach ($images as $in) {
277            $inString = \file_get_contents($in);
278            if ($inString === false) {
279                continue;
280            }
281
282            $im = \imagecreatefromstring($inString);
283            if ($im === false) {
284                continue;
285            }
286
287            $new = \imagescale($im, $resolution, $resolution);
288            if ($new === false) {
289                continue;
290            }
291
292            // Convert the image to grayscale and normalize the pixel values
293            $mnist = [];
294            for ($i = 0; $i < $resolution; ++$i) {
295                for ($j = 0; $j < $resolution; ++$j) {
296                    $pixel = \imagecolorat($new, $j, $i);
297                    $gray  = \round(
298                        (
299                            0.299 * (($pixel >> 16) & 0xFF)
300                            + 0.587 * (($pixel >> 8) & 0xFF)
301                            + 0.114 * ($pixel & 0xFF)
302                        ) / 255,
303                        3
304                    );
305
306                    $mnist[] = $gray;
307                }
308            }
309
310            for ($i = 0; $i < $size; ++$i) {
311                \fwrite($out, \pack('C', (int) \round($mnist[$i] * 255)));
312            }
313        }
314
315        \fclose($out);
316    }
317
318    /**
319     * Convert labels to MNIST format
320     *
321     * @param string[] $data Labels (one char per label)
322     * @param string   $out  Output path
323     *
324     * @return void
325     *
326     * @since 1.0.0
327     */
328    public static function labelsToMNIST(array $data, string $out) : void
329    {
330        // Only allows single char labels
331        $out = \fopen($out, 'wb');
332        if ($out === false) {
333            return; // @codeCoverageIgnore
334        }
335
336        \fwrite($out, \pack('N', 2049));
337        \fwrite($out, \pack('N', \count($data)));
338
339        foreach ($data as $e) {
340            \fwrite($out, \pack('C', $e));
341        }
342
343        \fclose($out);
344    }
345
346    /**
347     * Categorize an unknown image
348     *
349     * @param string $path       Path to the image to categorize/evaluate/match against the training data
350     * @param int    $comparison Amount of comparisons
351     * @param int    $limit      Limit (0 = unlimited)
352     *
353     * @return array
354     *
355     * @since 1.0.0
356     */
357    public function matchImage(string $path, int $comparison = 3, int $limit = 0) : array
358    {
359        $Xtest = $this->readImages($path, $limit);
360
361        return $this->kNearest($this->Xtrain, $this->ytrain, $Xtest, $comparison);
362    }
363}