1 | // This file is part of OpenCV project. |
2 | // It is subject to the license terms in the LICENSE file found in the top-level directory |
3 | // of this distribution and at http://opencv.org/license.html |
4 | |
5 | #include "../precomp.hpp" |
6 | #include "opencv2/core/hal/hal.hpp" |
7 | |
8 | #include "aruco_utils.hpp" |
9 | #include "predefined_dictionaries.hpp" |
10 | #include "apriltag/predefined_dictionaries_apriltag.hpp" |
11 | #include <opencv2/objdetect/aruco_dictionary.hpp> |
12 | |
13 | namespace cv { |
14 | namespace aruco { |
15 | |
16 | using namespace std; |
17 | |
18 | Dictionary::Dictionary(): markerSize(0), maxCorrectionBits(0) {} |
19 | |
20 | |
21 | Dictionary::Dictionary(const Mat &_bytesList, int _markerSize, int _maxcorr) { |
22 | markerSize = _markerSize; |
23 | maxCorrectionBits = _maxcorr; |
24 | bytesList = _bytesList; |
25 | } |
26 | |
27 | |
28 | bool Dictionary::readDictionary(const cv::FileNode& fn) { |
29 | int nMarkers = 0, _markerSize = 0; |
30 | if (fn.empty() || !readParameter(name: "nmarkers" , parameter&: nMarkers, node: fn) || !readParameter(name: "markersize" , parameter&: _markerSize, node: fn)) |
31 | return false; |
32 | Mat bytes(0, 0, CV_8UC1), marker(_markerSize, _markerSize, CV_8UC1); |
33 | std::string markerString; |
34 | for (int i = 0; i < nMarkers; i++) { |
35 | std::ostringstream ostr; |
36 | ostr << i; |
37 | if (!readParameter(name: "marker_" + ostr.str(), parameter&: markerString, node: fn)) |
38 | return false; |
39 | for (int j = 0; j < (int) markerString.size(); j++) |
40 | marker.at<unsigned char>(i0: j) = (markerString[j] == '0') ? 0 : 1; |
41 | bytes.push_back(m: Dictionary::getByteListFromBits(bits: marker)); |
42 | } |
43 | int _maxCorrectionBits = 0; |
44 | readParameter(name: "maxCorrectionBits" , parameter&: _maxCorrectionBits, node: fn); |
45 | *this = Dictionary(bytes, _markerSize, _maxCorrectionBits); |
46 | return true; |
47 | } |
48 | |
49 | void Dictionary::writeDictionary(FileStorage& fs, const String &name) |
50 | { |
51 | CV_Assert(fs.isOpened()); |
52 | |
53 | if (!name.empty()) |
54 | fs << name << "{" ; |
55 | |
56 | fs << "nmarkers" << bytesList.rows; |
57 | fs << "markersize" << markerSize; |
58 | fs << "maxCorrectionBits" << maxCorrectionBits; |
59 | for (int i = 0; i < bytesList.rows; i++) { |
60 | Mat row = bytesList.row(y: i);; |
61 | Mat bitMarker = getBitsFromByteList(byteList: row, markerSize); |
62 | std::ostringstream ostr; |
63 | ostr << i; |
64 | string markerName = "marker_" + ostr.str(); |
65 | string marker; |
66 | for (int j = 0; j < markerSize * markerSize; j++) |
67 | marker.push_back(c: bitMarker.at<uint8_t>(i0: j) + '0'); |
68 | fs << markerName << marker; |
69 | } |
70 | |
71 | if (!name.empty()) |
72 | fs << "}" ; |
73 | } |
74 | |
75 | |
76 | bool Dictionary::identify(const Mat &onlyBits, int &idx, int &rotation, double maxCorrectionRate) const { |
77 | CV_Assert(onlyBits.rows == markerSize && onlyBits.cols == markerSize); |
78 | |
79 | int maxCorrectionRecalculed = int(double(maxCorrectionBits) * maxCorrectionRate); |
80 | |
81 | // get as a byte list |
82 | Mat candidateBytes = getByteListFromBits(bits: onlyBits); |
83 | |
84 | idx = -1; // by default, not found |
85 | |
86 | // search closest marker in dict |
87 | for(int m = 0; m < bytesList.rows; m++) { |
88 | int currentMinDistance = markerSize * markerSize + 1; |
89 | int currentRotation = -1; |
90 | for(unsigned int r = 0; r < 4; r++) { |
91 | int currentHamming = cv::hal::normHamming( |
92 | a: bytesList.ptr(y: m)+r*candidateBytes.cols, |
93 | b: candidateBytes.ptr(), |
94 | n: candidateBytes.cols); |
95 | |
96 | if(currentHamming < currentMinDistance) { |
97 | currentMinDistance = currentHamming; |
98 | currentRotation = r; |
99 | } |
100 | } |
101 | |
102 | // if maxCorrection is fulfilled, return this one |
103 | if(currentMinDistance <= maxCorrectionRecalculed) { |
104 | idx = m; |
105 | rotation = currentRotation; |
106 | break; |
107 | } |
108 | } |
109 | |
110 | return idx != -1; |
111 | } |
112 | |
113 | |
114 | int Dictionary::getDistanceToId(InputArray bits, int id, bool allRotations) const { |
115 | |
116 | CV_Assert(id >= 0 && id < bytesList.rows); |
117 | |
118 | unsigned int nRotations = 4; |
119 | if(!allRotations) nRotations = 1; |
120 | |
121 | Mat candidateBytes = getByteListFromBits(bits: bits.getMat()); |
122 | int currentMinDistance = int(bits.total() * bits.total()); |
123 | for(unsigned int r = 0; r < nRotations; r++) { |
124 | int currentHamming = cv::hal::normHamming( |
125 | a: bytesList.ptr(y: id) + r*candidateBytes.cols, |
126 | b: candidateBytes.ptr(), |
127 | n: candidateBytes.cols); |
128 | |
129 | if(currentHamming < currentMinDistance) { |
130 | currentMinDistance = currentHamming; |
131 | } |
132 | } |
133 | return currentMinDistance; |
134 | } |
135 | |
136 | |
137 | void Dictionary::generateImageMarker(int id, int sidePixels, OutputArray _img, int borderBits) const { |
138 | CV_Assert(sidePixels >= (markerSize + 2*borderBits)); |
139 | CV_Assert(id < bytesList.rows); |
140 | CV_Assert(borderBits > 0); |
141 | |
142 | _img.create(rows: sidePixels, cols: sidePixels, CV_8UC1); |
143 | |
144 | // create small marker with 1 pixel per bin |
145 | Mat tinyMarker(markerSize + 2 * borderBits, markerSize + 2 * borderBits, CV_8UC1, |
146 | Scalar::all(v0: 0)); |
147 | Mat innerRegion = tinyMarker.rowRange(startrow: borderBits, endrow: tinyMarker.rows - borderBits) |
148 | .colRange(startcol: borderBits, endcol: tinyMarker.cols - borderBits); |
149 | // put inner bits |
150 | Mat bits = 255 * getBitsFromByteList(byteList: bytesList.rowRange(startrow: id, endrow: id + 1), markerSize); |
151 | CV_Assert(innerRegion.total() == bits.total()); |
152 | bits.copyTo(m: innerRegion); |
153 | |
154 | // resize tiny marker to output size |
155 | cv::resize(src: tinyMarker, dst: _img.getMat(), dsize: _img.getMat().size(), fx: 0, fy: 0, interpolation: INTER_NEAREST); |
156 | } |
157 | |
158 | |
159 | Mat Dictionary::getByteListFromBits(const Mat &bits) { |
160 | // integer ceil |
161 | int nbytes = (bits.cols * bits.rows + 8 - 1) / 8; |
162 | |
163 | Mat candidateByteList(1, nbytes, CV_8UC4, Scalar::all(v0: 0)); |
164 | unsigned char currentBit = 0; |
165 | int currentByte = 0; |
166 | |
167 | // the 4 rotations |
168 | uchar* rot0 = candidateByteList.ptr(); |
169 | uchar* rot1 = candidateByteList.ptr() + 1*nbytes; |
170 | uchar* rot2 = candidateByteList.ptr() + 2*nbytes; |
171 | uchar* rot3 = candidateByteList.ptr() + 3*nbytes; |
172 | |
173 | for(int row = 0; row < bits.rows; row++) { |
174 | for(int col = 0; col < bits.cols; col++) { |
175 | // circular shift |
176 | rot0[currentByte] <<= 1; |
177 | rot1[currentByte] <<= 1; |
178 | rot2[currentByte] <<= 1; |
179 | rot3[currentByte] <<= 1; |
180 | // set bit |
181 | rot0[currentByte] |= bits.at<uchar>(i0: row, i1: col); |
182 | rot1[currentByte] |= bits.at<uchar>(i0: col, i1: bits.cols - 1 - row); |
183 | rot2[currentByte] |= bits.at<uchar>(i0: bits.rows - 1 - row, i1: bits.cols - 1 - col); |
184 | rot3[currentByte] |= bits.at<uchar>(i0: bits.rows - 1 - col, i1: row); |
185 | currentBit++; |
186 | if(currentBit == 8) { |
187 | // next byte |
188 | currentBit = 0; |
189 | currentByte++; |
190 | } |
191 | } |
192 | } |
193 | return candidateByteList; |
194 | } |
195 | |
196 | |
197 | Mat Dictionary::getBitsFromByteList(const Mat &byteList, int markerSize) { |
198 | CV_Assert(byteList.total() > 0 && |
199 | byteList.total() >= (unsigned int)markerSize * markerSize / 8 && |
200 | byteList.total() <= (unsigned int)markerSize * markerSize / 8 + 1); |
201 | Mat bits(markerSize, markerSize, CV_8UC1, Scalar::all(v0: 0)); |
202 | |
203 | unsigned char base2List[] = { 128, 64, 32, 16, 8, 4, 2, 1 }; |
204 | int currentByteIdx = 0; |
205 | // we only need the bytes in normal rotation |
206 | unsigned char currentByte = byteList.ptr()[0]; |
207 | int currentBit = 0; |
208 | for(int row = 0; row < bits.rows; row++) { |
209 | for(int col = 0; col < bits.cols; col++) { |
210 | if(currentByte >= base2List[currentBit]) { |
211 | bits.at<unsigned char>(i0: row, i1: col) = 1; |
212 | currentByte -= base2List[currentBit]; |
213 | } |
214 | currentBit++; |
215 | if(currentBit == 8) { |
216 | currentByteIdx++; |
217 | currentByte = byteList.ptr()[currentByteIdx]; |
218 | // if not enough bits for one more byte, we are in the end |
219 | // update bit position accordingly |
220 | if(8 * (currentByteIdx + 1) > (int)bits.total()) |
221 | currentBit = 8 * (currentByteIdx + 1) - (int)bits.total(); |
222 | else |
223 | currentBit = 0; // ok, bits enough for next byte |
224 | } |
225 | } |
226 | } |
227 | return bits; |
228 | } |
229 | |
230 | |
231 | Dictionary getPredefinedDictionary(PredefinedDictionaryType name) { |
232 | // DictionaryData constructors calls |
233 | // moved out of globals so construted on first use, which allows lazy-loading of opencv dll |
234 | static const Dictionary DICT_ARUCO_DATA = Dictionary(Mat(1024, (5 * 5 + 7) / 8, CV_8UC4, (uchar*)DICT_ARUCO_BYTES), 5, 0); |
235 | |
236 | static const Dictionary DICT_4X4_50_DATA = Dictionary(Mat(50, (4 * 4 + 7) / 8, CV_8UC4, (uchar*)DICT_4X4_1000_BYTES), 4, 1); |
237 | static const Dictionary DICT_4X4_100_DATA = Dictionary(Mat(100, (4 * 4 + 7) / 8, CV_8UC4, (uchar*)DICT_4X4_1000_BYTES), 4, 1); |
238 | static const Dictionary DICT_4X4_250_DATA = Dictionary(Mat(250, (4 * 4 + 7) / 8, CV_8UC4, (uchar*)DICT_4X4_1000_BYTES), 4, 1); |
239 | static const Dictionary DICT_4X4_1000_DATA = Dictionary(Mat(1000, (4 * 4 + 7) / 8, CV_8UC4, (uchar*)DICT_4X4_1000_BYTES), 4, 0); |
240 | |
241 | static const Dictionary DICT_5X5_50_DATA = Dictionary(Mat(50, (5 * 5 + 7) / 8, CV_8UC4, (uchar*)DICT_5X5_1000_BYTES), 5, 3); |
242 | static const Dictionary DICT_5X5_100_DATA = Dictionary(Mat(100, (5 * 5 + 7) / 8, CV_8UC4, (uchar*)DICT_5X5_1000_BYTES), 5, 3); |
243 | static const Dictionary DICT_5X5_250_DATA = Dictionary(Mat(250, (5 * 5 + 7) / 8, CV_8UC4, (uchar*)DICT_5X5_1000_BYTES), 5, 2); |
244 | static const Dictionary DICT_5X5_1000_DATA = Dictionary(Mat(1000, (5 * 5 + 7) / 8, CV_8UC4, (uchar*)DICT_5X5_1000_BYTES), 5, 2); |
245 | |
246 | static const Dictionary DICT_6X6_50_DATA = Dictionary(Mat(50, (6 * 6 + 7) / 8, CV_8UC4, (uchar*)DICT_6X6_1000_BYTES), 6, 6); |
247 | static const Dictionary DICT_6X6_100_DATA = Dictionary(Mat(100, (6 * 6 + 7) / 8, CV_8UC4, (uchar*)DICT_6X6_1000_BYTES), 6, 5); |
248 | static const Dictionary DICT_6X6_250_DATA = Dictionary(Mat(250, (6 * 6 + 7) / 8, CV_8UC4, (uchar*)DICT_6X6_1000_BYTES), 6, 5); |
249 | static const Dictionary DICT_6X6_1000_DATA = Dictionary(Mat(1000, (6 * 6 + 7) / 8, CV_8UC4, (uchar*)DICT_6X6_1000_BYTES), 6, 4); |
250 | |
251 | static const Dictionary DICT_7X7_50_DATA = Dictionary(Mat(50, (7 * 7 + 7) / 8, CV_8UC4, (uchar*)DICT_7X7_1000_BYTES), 7, 9); |
252 | static const Dictionary DICT_7X7_100_DATA = Dictionary(Mat(100, (7 * 7 + 7) / 8, CV_8UC4, (uchar*)DICT_7X7_1000_BYTES), 7, 8); |
253 | static const Dictionary DICT_7X7_250_DATA = Dictionary(Mat(250, (7 * 7 + 7) / 8, CV_8UC4, (uchar*)DICT_7X7_1000_BYTES), 7, 8); |
254 | static const Dictionary DICT_7X7_1000_DATA = Dictionary(Mat(1000, (7 * 7 + 7) / 8, CV_8UC4, (uchar*)DICT_7X7_1000_BYTES), 7, 6); |
255 | |
256 | static const Dictionary DICT_APRILTAG_16h5_DATA = Dictionary(Mat(30, (4 * 4 + 7) / 8, CV_8UC4, (uchar*)DICT_APRILTAG_16h5_BYTES), 4, 0); |
257 | static const Dictionary DICT_APRILTAG_25h9_DATA = Dictionary(Mat(35, (5 * 5 + 7) / 8, CV_8UC4, (uchar*)DICT_APRILTAG_25h9_BYTES), 5, 0); |
258 | static const Dictionary DICT_APRILTAG_36h10_DATA = Dictionary(Mat(2320, (6 * 6 + 7) / 8, CV_8UC4, (uchar*)DICT_APRILTAG_36h10_BYTES), 6, 0); |
259 | static const Dictionary DICT_APRILTAG_36h11_DATA = Dictionary(Mat(587, (6 * 6 + 7) / 8, CV_8UC4, (uchar*)DICT_APRILTAG_36h11_BYTES), 6, 0); |
260 | |
261 | static const Dictionary DICT_ARUCO_MIP_36h12_DATA = Dictionary(Mat(250, (6 * 6 + 7) / 8, CV_8UC4, (uchar*)DICT_ARUCO_MIP_36h12_BYTES), 6, 12); |
262 | |
263 | switch(name) { |
264 | |
265 | case DICT_ARUCO_ORIGINAL: |
266 | return Dictionary(DICT_ARUCO_DATA); |
267 | |
268 | case DICT_4X4_50: |
269 | return Dictionary(DICT_4X4_50_DATA); |
270 | case DICT_4X4_100: |
271 | return Dictionary(DICT_4X4_100_DATA); |
272 | case DICT_4X4_250: |
273 | return Dictionary(DICT_4X4_250_DATA); |
274 | case DICT_4X4_1000: |
275 | return Dictionary(DICT_4X4_1000_DATA); |
276 | |
277 | case DICT_5X5_50: |
278 | return Dictionary(DICT_5X5_50_DATA); |
279 | case DICT_5X5_100: |
280 | return Dictionary(DICT_5X5_100_DATA); |
281 | case DICT_5X5_250: |
282 | return Dictionary(DICT_5X5_250_DATA); |
283 | case DICT_5X5_1000: |
284 | return Dictionary(DICT_5X5_1000_DATA); |
285 | |
286 | case DICT_6X6_50: |
287 | return Dictionary(DICT_6X6_50_DATA); |
288 | case DICT_6X6_100: |
289 | return Dictionary(DICT_6X6_100_DATA); |
290 | case DICT_6X6_250: |
291 | return Dictionary(DICT_6X6_250_DATA); |
292 | case DICT_6X6_1000: |
293 | return Dictionary(DICT_6X6_1000_DATA); |
294 | |
295 | case DICT_7X7_50: |
296 | return Dictionary(DICT_7X7_50_DATA); |
297 | case DICT_7X7_100: |
298 | return Dictionary(DICT_7X7_100_DATA); |
299 | case DICT_7X7_250: |
300 | return Dictionary(DICT_7X7_250_DATA); |
301 | case DICT_7X7_1000: |
302 | return Dictionary(DICT_7X7_1000_DATA); |
303 | |
304 | case DICT_APRILTAG_16h5: |
305 | return Dictionary(DICT_APRILTAG_16h5_DATA); |
306 | case DICT_APRILTAG_25h9: |
307 | return Dictionary(DICT_APRILTAG_25h9_DATA); |
308 | case DICT_APRILTAG_36h10: |
309 | return Dictionary(DICT_APRILTAG_36h10_DATA); |
310 | case DICT_APRILTAG_36h11: |
311 | return Dictionary(DICT_APRILTAG_36h11_DATA); |
312 | |
313 | case DICT_ARUCO_MIP_36h12: |
314 | return Dictionary(DICT_ARUCO_MIP_36h12_DATA); |
315 | } |
316 | return Dictionary(DICT_4X4_50_DATA); |
317 | } |
318 | |
319 | |
320 | Dictionary getPredefinedDictionary(int dict) { |
321 | return getPredefinedDictionary(name: PredefinedDictionaryType(dict)); |
322 | } |
323 | |
324 | |
325 | /** |
326 | * @brief Generates a random marker Mat of size markerSize x markerSize |
327 | */ |
328 | static Mat _generateRandomMarker(int markerSize, RNG &rng) { |
329 | Mat marker(markerSize, markerSize, CV_8UC1, Scalar::all(v0: 0)); |
330 | for(int i = 0; i < markerSize; i++) { |
331 | for(int j = 0; j < markerSize; j++) { |
332 | unsigned char bit = (unsigned char) (rng.uniform(a: 0,b: 2)); |
333 | marker.at<unsigned char>(i0: i, i1: j) = bit; |
334 | } |
335 | } |
336 | return marker; |
337 | } |
338 | |
339 | /** |
340 | * @brief Calculate selfDistance of the codification of a marker Mat. Self distance is the Hamming |
341 | * distance of the marker to itself in the other rotations. |
342 | * See S. Garrido-Jurado, R. Muñoz-Salinas, F. J. Madrid-Cuevas, and M. J. Marín-Jiménez. 2014. |
343 | * "Automatic generation and detection of highly reliable fiducial markers under occlusion". |
344 | * Pattern Recogn. 47, 6 (June 2014), 2280-2292. DOI=10.1016/j.patcog.2014.01.005 |
345 | */ |
346 | static int _getSelfDistance(const Mat &marker) { |
347 | Mat bytes = Dictionary::getByteListFromBits(bits: marker); |
348 | int minHamming = (int)marker.total() + 1; |
349 | for(int r = 1; r < 4; r++) { |
350 | int currentHamming = cv::hal::normHamming(a: bytes.ptr(), b: bytes.ptr() + bytes.cols*r, n: bytes.cols); |
351 | if(currentHamming < minHamming) minHamming = currentHamming; |
352 | } |
353 | return minHamming; |
354 | } |
355 | |
356 | |
357 | Dictionary extendDictionary(int nMarkers, int markerSize, const Dictionary &baseDictionary, int randomSeed) { |
358 | CV_Assert(nMarkers > 0); |
359 | RNG rng((uint64)(randomSeed)); |
360 | |
361 | Dictionary out = Dictionary(Mat(), markerSize); |
362 | out.markerSize = markerSize; |
363 | |
364 | // theoretical maximum intermarker distance |
365 | // See S. Garrido-Jurado, R. Muñoz-Salinas, F. J. Madrid-Cuevas, and M. J. Marín-Jiménez. 2014. |
366 | // "Automatic generation and detection of highly reliable fiducial markers under occlusion". |
367 | // Pattern Recogn. 47, 6 (June 2014), 2280-2292. DOI=10.1016/j.patcog.2014.01.005 |
368 | int C = (int)std::floor(x: float(markerSize * markerSize) / 4.f); |
369 | int tau = 2 * (int)std::floor(x: float(C) * 4.f / 3.f); |
370 | |
371 | // if baseDictionary is provided, calculate its intermarker distance |
372 | if(baseDictionary.bytesList.rows > 0) { |
373 | CV_Assert(baseDictionary.markerSize == markerSize); |
374 | out.bytesList = baseDictionary.bytesList.rowRange(startrow: 0, endrow: min(a: nMarkers, b: baseDictionary.bytesList.rows)).clone(); |
375 | |
376 | int minDistance = markerSize * markerSize + 1; |
377 | for(int i = 0; i < out.bytesList.rows; i++) { |
378 | Mat markerBytes = out.bytesList.rowRange(startrow: i, endrow: i + 1); |
379 | Mat markerBits = Dictionary::getBitsFromByteList(byteList: markerBytes, markerSize); |
380 | minDistance = min(a: minDistance, b: _getSelfDistance(marker: markerBits)); |
381 | for(int j = i + 1; j < out.bytesList.rows; j++) { |
382 | minDistance = min(a: minDistance, b: out.getDistanceToId(bits: markerBits, id: j)); |
383 | } |
384 | } |
385 | tau = minDistance; |
386 | } |
387 | |
388 | // current best option |
389 | int bestTau = 0; |
390 | Mat bestMarker; |
391 | |
392 | // after these number of unproductive iterations, the best option is accepted |
393 | const int maxUnproductiveIterations = 5000; |
394 | int unproductiveIterations = 0; |
395 | |
396 | while(out.bytesList.rows < nMarkers) { |
397 | Mat currentMarker = _generateRandomMarker(markerSize, rng); |
398 | |
399 | int selfDistance = _getSelfDistance(marker: currentMarker); |
400 | int minDistance = selfDistance; |
401 | |
402 | // if self distance is better or equal than current best option, calculate distance |
403 | // to previous accepted markers |
404 | if(selfDistance >= bestTau) { |
405 | for(int i = 0; i < out.bytesList.rows; i++) { |
406 | int currentDistance = out.getDistanceToId(bits: currentMarker, id: i); |
407 | minDistance = min(a: currentDistance, b: minDistance); |
408 | if(minDistance <= bestTau) { |
409 | break; |
410 | } |
411 | } |
412 | } |
413 | |
414 | // if distance is high enough, accept the marker |
415 | if(minDistance >= tau) { |
416 | unproductiveIterations = 0; |
417 | bestTau = 0; |
418 | Mat bytes = Dictionary::getByteListFromBits(bits: currentMarker); |
419 | out.bytesList.push_back(m: bytes); |
420 | } else { |
421 | unproductiveIterations++; |
422 | |
423 | // if distance is not enough, but is better than the current best option |
424 | if(minDistance > bestTau) { |
425 | bestTau = minDistance; |
426 | bestMarker = currentMarker; |
427 | } |
428 | |
429 | // if number of unproductive iterarions has been reached, accept the current best option |
430 | if(unproductiveIterations == maxUnproductiveIterations) { |
431 | unproductiveIterations = 0; |
432 | tau = bestTau; |
433 | bestTau = 0; |
434 | Mat bytes = Dictionary::getByteListFromBits(bits: bestMarker); |
435 | out.bytesList.push_back(m: bytes); |
436 | } |
437 | } |
438 | } |
439 | |
440 | // update the maximum number of correction bits for the generated dictionary |
441 | out.maxCorrectionBits = (tau - 1) / 2; |
442 | |
443 | return out; |
444 | } |
445 | |
446 | } |
447 | } |
448 | |