Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
97.00% |
97 / 100 |
|
87.50% |
7 / 8 |
CRAP | |
0.00% |
0 / 1 |
BasicOcr | |
97.00% |
97 / 100 |
|
87.50% |
7 / 8 |
46 | |
0.00% |
0 / 1 |
trainWith | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
readImages | |
100.00% |
20 / 20 |
|
100.00% |
1 / 1 |
15 | |||
readLabels | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
11 | |||
kNearest | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
4 | |||
getDistances | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
imagesToMNIST | |
90.91% |
30 / 33 |
|
0.00% |
0 / 1 |
9.06 | |||
labelsToMNIST | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
matchImage | |
100.00% |
2 / 2 |
|
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 | */ |
13 | declare(strict_types=1); |
14 | |
15 | namespace phpOMS\Ai\Ocr; |
16 | |
17 | use phpOMS\Math\Topology\MetricsND; |
18 | use 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 | */ |
28 | final 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 | } |