From 68099c2d5b971e36a5d16c10e62361b74e1e655a Mon Sep 17 00:00:00 2001 From: zhoujun Date: Wed, 8 Feb 2023 15:52:30 +0800 Subject: [PATCH] add db for benchmark (#8959) * Add custom detection and recognition model usage instructions in re * update * Add custom detection and recognition model usage instructions in re * add db net for benchmark * rename benckmark to PaddleOCR_benchmark * add addict to req * rename --- benchmark/PaddleOCR_DBNet/.gitattributes | 2 + benchmark/PaddleOCR_DBNet/.gitignore | 16 + benchmark/PaddleOCR_DBNet/LICENSE.md | 201 ++++++++ benchmark/PaddleOCR_DBNet/README.MD | 132 +++++ benchmark/PaddleOCR_DBNet/base/__init__.py | 2 + .../PaddleOCR_DBNet/base/base_dataset.py | 87 ++++ .../PaddleOCR_DBNet/base/base_trainer.py | 250 +++++++++ .../PaddleOCR_DBNet/config/SynthText.yaml | 40 ++ .../SynthText_resnet18_FPN_DBhead_polyLR.yaml | 65 +++ .../PaddleOCR_DBNet/config/icdar2015.yaml | 69 +++ ...ar2015_dcn_resnet18_FPN_DBhead_polyLR.yaml | 82 +++ .../icdar2015_resnet18_FPN_DBhead_polyLR.yaml | 82 +++ ...5_resnet18_FPN_DBhead_polyLR_finetune.yaml | 83 +++ .../icdar2015_resnet50_FPN_DBhead_polyLR.yaml | 79 +++ .../PaddleOCR_DBNet/config/open_dataset.yaml | 73 +++ ...ataset_dcn_resnet50_FPN_DBhead_polyLR.yaml | 86 ++++ ...n_dataset_resnest50_FPN_DBhead_polyLR.yaml | 86 ++++ ...en_dataset_resnet18_FPN_DBhead_polyLR.yaml | 93 ++++ .../PaddleOCR_DBNet/data_loader/__init__.py | 106 ++++ .../PaddleOCR_DBNet/data_loader/dataset.py | 181 +++++++ .../data_loader/modules/__init__.py | 8 + .../data_loader/modules/augment.py | 304 +++++++++++ .../data_loader/modules/iaa_augment.py | 71 +++ .../data_loader/modules/make_border_map.py | 143 ++++++ .../data_loader/modules/make_shrink_map.py | 133 +++++ .../data_loader/modules/random_crop_data.py | 206 ++++++++ benchmark/PaddleOCR_DBNet/environment.yml | 21 + benchmark/PaddleOCR_DBNet/eval.sh | 1 + benchmark/PaddleOCR_DBNet/generate_lists.sh | 17 + benchmark/PaddleOCR_DBNet/imgs/paper/db.jpg | Bin 0 -> 194472 bytes benchmark/PaddleOCR_DBNet/models/__init__.py | 20 + .../models/backbone/__init__.py | 18 + .../PaddleOCR_DBNet/models/backbone/resnet.py | 375 ++++++++++++++ benchmark/PaddleOCR_DBNet/models/basic.py | 37 ++ .../PaddleOCR_DBNet/models/head/DBHead.py | 138 +++++ .../PaddleOCR_DBNet/models/head/__init__.py | 13 + .../PaddleOCR_DBNet/models/losses/DB_loss.py | 49 ++ .../PaddleOCR_DBNet/models/losses/__init__.py | 16 + .../models/losses/basic_loss.py | 97 ++++ benchmark/PaddleOCR_DBNet/models/model.py | 39 ++ benchmark/PaddleOCR_DBNet/models/neck/FPN.py | 84 +++ .../PaddleOCR_DBNet/models/neck/__init__.py | 13 + benchmark/PaddleOCR_DBNet/multi_gpu_train.sh | 2 + .../post_processing/__init__.py | 13 + .../seg_detector_representer.py | 192 +++++++ benchmark/PaddleOCR_DBNet/predict.sh | 1 + benchmark/PaddleOCR_DBNet/requirement.txt | 13 + .../PaddleOCR_DBNet/singlel_gpu_train.sh | 1 + benchmark/PaddleOCR_DBNet/test/README.MD | 8 + .../test_tipc/benchmark_train.sh | 287 +++++++++++ .../PaddleOCR_DBNet/test_tipc/common_func.sh | 67 +++ .../det_res50_db/train_infer_python.txt | 59 +++ .../PaddleOCR_DBNet/test_tipc/prepare.sh | 54 ++ .../test_tipc/test_train_inference_python.sh | 340 +++++++++++++ benchmark/PaddleOCR_DBNet/tools/__init__.py | 3 + benchmark/PaddleOCR_DBNet/tools/eval.py | 87 ++++ .../PaddleOCR_DBNet/tools/export_model.py | 57 +++ benchmark/PaddleOCR_DBNet/tools/infer.py | 298 +++++++++++ benchmark/PaddleOCR_DBNet/tools/predict.py | 178 +++++++ benchmark/PaddleOCR_DBNet/tools/train.py | 61 +++ benchmark/PaddleOCR_DBNet/trainer/__init__.py | 4 + benchmark/PaddleOCR_DBNet/trainer/trainer.py | 230 +++++++++ benchmark/PaddleOCR_DBNet/utils/__init__.py | 8 + .../utils/cal_recall/__init__.py | 5 + .../utils/cal_recall/rrc_evaluation_funcs.py | 479 ++++++++++++++++++ .../utils/cal_recall/script.py | 350 +++++++++++++ .../PaddleOCR_DBNet/utils/compute_mean_std.py | 46 ++ .../PaddleOCR_DBNet/utils/make_trainfile.py | 21 + benchmark/PaddleOCR_DBNet/utils/metrics.py | 58 +++ .../utils/ocr_metric/__init__.py | 19 + .../utils/ocr_metric/icdar2015/__init__.py | 5 + .../icdar2015/detection/__init__.py | 0 .../ocr_metric/icdar2015/detection/deteval.py | 389 ++++++++++++++ .../icdar2015/detection/icdar2013.py | 346 +++++++++++++ .../ocr_metric/icdar2015/detection/iou.py | 263 ++++++++++ .../icdar2015/detection/mtwi2018.py | 335 ++++++++++++ .../utils/ocr_metric/icdar2015/quad_metric.py | 98 ++++ benchmark/PaddleOCR_DBNet/utils/profiler.py | 110 ++++ benchmark/PaddleOCR_DBNet/utils/schedulers.py | 64 +++ benchmark/PaddleOCR_DBNet/utils/util.py | 367 ++++++++++++++ 80 files changed, 8536 insertions(+) create mode 100644 benchmark/PaddleOCR_DBNet/.gitattributes create mode 100644 benchmark/PaddleOCR_DBNet/.gitignore create mode 100644 benchmark/PaddleOCR_DBNet/LICENSE.md create mode 100644 benchmark/PaddleOCR_DBNet/README.MD create mode 100644 benchmark/PaddleOCR_DBNet/base/__init__.py create mode 100644 benchmark/PaddleOCR_DBNet/base/base_dataset.py create mode 100644 benchmark/PaddleOCR_DBNet/base/base_trainer.py create mode 100644 benchmark/PaddleOCR_DBNet/config/SynthText.yaml create mode 100644 benchmark/PaddleOCR_DBNet/config/SynthText_resnet18_FPN_DBhead_polyLR.yaml create mode 100644 benchmark/PaddleOCR_DBNet/config/icdar2015.yaml create mode 100644 benchmark/PaddleOCR_DBNet/config/icdar2015_dcn_resnet18_FPN_DBhead_polyLR.yaml create mode 100644 benchmark/PaddleOCR_DBNet/config/icdar2015_resnet18_FPN_DBhead_polyLR.yaml create mode 100644 benchmark/PaddleOCR_DBNet/config/icdar2015_resnet18_FPN_DBhead_polyLR_finetune.yaml create mode 100644 benchmark/PaddleOCR_DBNet/config/icdar2015_resnet50_FPN_DBhead_polyLR.yaml create mode 100644 benchmark/PaddleOCR_DBNet/config/open_dataset.yaml create mode 100644 benchmark/PaddleOCR_DBNet/config/open_dataset_dcn_resnet50_FPN_DBhead_polyLR.yaml create mode 100644 benchmark/PaddleOCR_DBNet/config/open_dataset_resnest50_FPN_DBhead_polyLR.yaml create mode 100644 benchmark/PaddleOCR_DBNet/config/open_dataset_resnet18_FPN_DBhead_polyLR.yaml create mode 100644 benchmark/PaddleOCR_DBNet/data_loader/__init__.py create mode 100644 benchmark/PaddleOCR_DBNet/data_loader/dataset.py create mode 100644 benchmark/PaddleOCR_DBNet/data_loader/modules/__init__.py create mode 100644 benchmark/PaddleOCR_DBNet/data_loader/modules/augment.py create mode 100644 benchmark/PaddleOCR_DBNet/data_loader/modules/iaa_augment.py create mode 100644 benchmark/PaddleOCR_DBNet/data_loader/modules/make_border_map.py create mode 100644 benchmark/PaddleOCR_DBNet/data_loader/modules/make_shrink_map.py create mode 100644 benchmark/PaddleOCR_DBNet/data_loader/modules/random_crop_data.py create mode 100644 benchmark/PaddleOCR_DBNet/environment.yml create mode 100644 benchmark/PaddleOCR_DBNet/eval.sh create mode 100644 benchmark/PaddleOCR_DBNet/generate_lists.sh create mode 100644 benchmark/PaddleOCR_DBNet/imgs/paper/db.jpg create mode 100644 benchmark/PaddleOCR_DBNet/models/__init__.py create mode 100644 benchmark/PaddleOCR_DBNet/models/backbone/__init__.py create mode 100644 benchmark/PaddleOCR_DBNet/models/backbone/resnet.py create mode 100644 benchmark/PaddleOCR_DBNet/models/basic.py create mode 100644 benchmark/PaddleOCR_DBNet/models/head/DBHead.py create mode 100644 benchmark/PaddleOCR_DBNet/models/head/__init__.py create mode 100644 benchmark/PaddleOCR_DBNet/models/losses/DB_loss.py create mode 100644 benchmark/PaddleOCR_DBNet/models/losses/__init__.py create mode 100644 benchmark/PaddleOCR_DBNet/models/losses/basic_loss.py create mode 100644 benchmark/PaddleOCR_DBNet/models/model.py create mode 100644 benchmark/PaddleOCR_DBNet/models/neck/FPN.py create mode 100644 benchmark/PaddleOCR_DBNet/models/neck/__init__.py create mode 100644 benchmark/PaddleOCR_DBNet/multi_gpu_train.sh create mode 100644 benchmark/PaddleOCR_DBNet/post_processing/__init__.py create mode 100644 benchmark/PaddleOCR_DBNet/post_processing/seg_detector_representer.py create mode 100644 benchmark/PaddleOCR_DBNet/predict.sh create mode 100644 benchmark/PaddleOCR_DBNet/requirement.txt create mode 100644 benchmark/PaddleOCR_DBNet/singlel_gpu_train.sh create mode 100644 benchmark/PaddleOCR_DBNet/test/README.MD create mode 100644 benchmark/PaddleOCR_DBNet/test_tipc/benchmark_train.sh create mode 100644 benchmark/PaddleOCR_DBNet/test_tipc/common_func.sh create mode 100644 benchmark/PaddleOCR_DBNet/test_tipc/configs/det_res50_db/train_infer_python.txt create mode 100644 benchmark/PaddleOCR_DBNet/test_tipc/prepare.sh create mode 100644 benchmark/PaddleOCR_DBNet/test_tipc/test_train_inference_python.sh create mode 100644 benchmark/PaddleOCR_DBNet/tools/__init__.py create mode 100644 benchmark/PaddleOCR_DBNet/tools/eval.py create mode 100644 benchmark/PaddleOCR_DBNet/tools/export_model.py create mode 100644 benchmark/PaddleOCR_DBNet/tools/infer.py create mode 100644 benchmark/PaddleOCR_DBNet/tools/predict.py create mode 100644 benchmark/PaddleOCR_DBNet/tools/train.py create mode 100644 benchmark/PaddleOCR_DBNet/trainer/__init__.py create mode 100644 benchmark/PaddleOCR_DBNet/trainer/trainer.py create mode 100644 benchmark/PaddleOCR_DBNet/utils/__init__.py create mode 100644 benchmark/PaddleOCR_DBNet/utils/cal_recall/__init__.py create mode 100644 benchmark/PaddleOCR_DBNet/utils/cal_recall/rrc_evaluation_funcs.py create mode 100644 benchmark/PaddleOCR_DBNet/utils/cal_recall/script.py create mode 100644 benchmark/PaddleOCR_DBNet/utils/compute_mean_std.py create mode 100644 benchmark/PaddleOCR_DBNet/utils/make_trainfile.py create mode 100644 benchmark/PaddleOCR_DBNet/utils/metrics.py create mode 100644 benchmark/PaddleOCR_DBNet/utils/ocr_metric/__init__.py create mode 100644 benchmark/PaddleOCR_DBNet/utils/ocr_metric/icdar2015/__init__.py create mode 100644 benchmark/PaddleOCR_DBNet/utils/ocr_metric/icdar2015/detection/__init__.py create mode 100644 benchmark/PaddleOCR_DBNet/utils/ocr_metric/icdar2015/detection/deteval.py create mode 100644 benchmark/PaddleOCR_DBNet/utils/ocr_metric/icdar2015/detection/icdar2013.py create mode 100644 benchmark/PaddleOCR_DBNet/utils/ocr_metric/icdar2015/detection/iou.py create mode 100644 benchmark/PaddleOCR_DBNet/utils/ocr_metric/icdar2015/detection/mtwi2018.py create mode 100644 benchmark/PaddleOCR_DBNet/utils/ocr_metric/icdar2015/quad_metric.py create mode 100644 benchmark/PaddleOCR_DBNet/utils/profiler.py create mode 100644 benchmark/PaddleOCR_DBNet/utils/schedulers.py create mode 100644 benchmark/PaddleOCR_DBNet/utils/util.py diff --git a/benchmark/PaddleOCR_DBNet/.gitattributes b/benchmark/PaddleOCR_DBNet/.gitattributes new file mode 100644 index 00000000..8543e0a7 --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/.gitattributes @@ -0,0 +1,2 @@ +*.html linguist-language=python +*.ipynb linguist-language=python \ No newline at end of file diff --git a/benchmark/PaddleOCR_DBNet/.gitignore b/benchmark/PaddleOCR_DBNet/.gitignore new file mode 100644 index 00000000..cef1c73b --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/.gitignore @@ -0,0 +1,16 @@ +.DS_Store +*.pth +*.pyc +*.pyo +*.log +*.tmp +*.pkl +__pycache__/ +.idea/ +output/ +test/*.jpg +datasets/ +index/ +train_log/ +log/ +profiling_log/ \ No newline at end of file diff --git a/benchmark/PaddleOCR_DBNet/LICENSE.md b/benchmark/PaddleOCR_DBNet/LICENSE.md new file mode 100644 index 00000000..b09cd785 --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/LICENSE.md @@ -0,0 +1,201 @@ +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/benchmark/PaddleOCR_DBNet/README.MD b/benchmark/PaddleOCR_DBNet/README.MD new file mode 100644 index 00000000..dbc07faa --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/README.MD @@ -0,0 +1,132 @@ +# Real-time Scene Text Detection with Differentiable Binarization + +**note**: some code is inherited from [WenmuZhou/DBNet.pytorch](https://github.com/WenmuZhou/DBNet.pytorch) + +[中文解读](https://zhuanlan.zhihu.com/p/94677957) + +![network](imgs/paper/db.jpg) + +## update +2020-06-07: 添加灰度图训练,训练灰度图时需要在配置里移除`dataset.args.transforms.Normalize` + +## Install Using Conda +``` +conda env create -f environment.yml +git clone https://github.com/WenmuZhou/DBNet.paddle.git +cd DBNet.paddle/ +``` + +or +## Install Manually +```bash +conda create -n dbnet python=3.6 +conda activate dbnet + +conda install ipython pip + +# python dependencies +pip install -r requirement.txt + +# clone repo +git clone https://github.com/WenmuZhou/DBNet.paddle.git +cd DBNet.paddle/ + +``` + +## Requirements +* paddlepaddle 2.4+ + +## Download + +TBD + +## Data Preparation + +Training data: prepare a text `train.txt` in the following format, use '\t' as a separator +``` +./datasets/train/img/001.jpg ./datasets/train/gt/001.txt +``` + +Validation data: prepare a text `test.txt` in the following format, use '\t' as a separator +``` +./datasets/test/img/001.jpg ./datasets/test/gt/001.txt +``` +- Store images in the `img` folder +- Store groundtruth in the `gt` folder + +The groundtruth can be `.txt` files, with the following format: +``` +x1, y1, x2, y2, x3, y3, x4, y4, annotation +``` + + +## Train +1. config the `dataset['train']['dataset'['data_path']'`,`dataset['validate']['dataset'['data_path']`in [config/icdar2015_resnet18_fpn_DBhead_polyLR.yaml](cconfig/icdar2015_resnet18_fpn_DBhead_polyLR.yaml) +* . single gpu train +```bash +bash singlel_gpu_train.sh +``` +* . Multi-gpu training +```bash +bash multi_gpu_train.sh +``` +## Test + +[eval.py](tools/eval.py) is used to test model on test dataset + +1. config `model_path` in [eval.sh](eval.sh) +2. use following script to test +```bash +bash eval.sh +``` + +## Predict +[predict.py](tools/predict.py) Can be used to inference on all images in a folder +1. config `model_path`,`input_folder`,`output_folder` in [predict.sh](predict.sh) +2. use following script to predict +``` +bash predict.sh +``` +You can change the `model_path` in the `predict.sh` file to your model location. + +tips: if result is not good, you can change `thre` in [predict.sh](predict.sh) + +## Export Model + +[export_model.py](tools/export_model.py) Can be used to inference on all images in a folder + +use following script to export inference model +``` +python tools/export_model.py --config_file config/icdar2015_resnet50_FPN_DBhead_polyLR.yaml -o trainer.resume_checkpoint=model_best.pth trainer.output_dir=output/infer +``` + +## Paddle Inference infer + +[infer.py](tools/infer.py) Can be used to inference on all images in a folder + +use following script to export inference model +``` +python tools/infer.py --model-dir=output/infer/ --img-path imgs/paper/db.jpg +``` + +

Performance

+ +### [ICDAR 2015](http://rrc.cvc.uab.es/?ch=4) +only train on ICDAR2015 dataset + +| Method | image size (short size) |learning rate | Precision (%) | Recall (%) | F-measure (%) | FPS | +|:--------------------------:|:-------:|:--------:|:--------:|:------------:|:---------------:|:-----:| +| ImageNet-resnet50-FPN-DBHead(torch) |736 |1e-3|90.19 | 78.14 | 83.88 | 27 | +| ImageNet-resnet50-FPN-DBHead(paddle) |736 |1e-3| 89.47 | 79.03 | 83.92 | 27 | +| ImageNet-resnet50-FPN-DBHead(paddle_amp) |736 |1e-3| 88.62 | 79.95 | 84.06 | 27 | + + +### examples +TBD + + +### reference +1. https://arxiv.org/pdf/1911.08947.pdf +2. https://github.com/WenmuZhou/DBNet.pytorch + +**If this repository helps you,please star it. Thanks.** diff --git a/benchmark/PaddleOCR_DBNet/base/__init__.py b/benchmark/PaddleOCR_DBNet/base/__init__.py new file mode 100644 index 00000000..223e9e02 --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/base/__init__.py @@ -0,0 +1,2 @@ +from .base_trainer import BaseTrainer +from .base_dataset import BaseDataSet \ No newline at end of file diff --git a/benchmark/PaddleOCR_DBNet/base/base_dataset.py b/benchmark/PaddleOCR_DBNet/base/base_dataset.py new file mode 100644 index 00000000..4a839a8f --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/base/base_dataset.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +# @Time : 2019/12/4 13:12 +# @Author : zhoujun +import copy +from paddle.io import Dataset +from data_loader.modules import * + + +class BaseDataSet(Dataset): + def __init__(self, + data_path: str, + img_mode, + pre_processes, + filter_keys, + ignore_tags, + transform=None, + target_transform=None): + assert img_mode in ['RGB', 'BRG', 'GRAY'] + self.ignore_tags = ignore_tags + self.data_list = self.load_data(data_path) + item_keys = [ + 'img_path', 'img_name', 'text_polys', 'texts', 'ignore_tags' + ] + for item in item_keys: + assert item in self.data_list[ + 0], 'data_list from load_data must contains {}'.format( + item_keys) + self.img_mode = img_mode + self.filter_keys = filter_keys + self.transform = transform + self.target_transform = target_transform + self._init_pre_processes(pre_processes) + + def _init_pre_processes(self, pre_processes): + self.aug = [] + if pre_processes is not None: + for aug in pre_processes: + if 'args' not in aug: + args = {} + else: + args = aug['args'] + if isinstance(args, dict): + cls = eval(aug['type'])(**args) + else: + cls = eval(aug['type'])(args) + self.aug.append(cls) + + def load_data(self, data_path: str) -> list: + """ + 把数据加载为一个list: + :params data_path: 存储数据的文件夹或者文件 + return a dict ,包含了,'img_path','img_name','text_polys','texts','ignore_tags' + """ + raise NotImplementedError + + def apply_pre_processes(self, data): + for aug in self.aug: + data = aug(data) + return data + + def __getitem__(self, index): + try: + data = copy.deepcopy(self.data_list[index]) + im = cv2.imread(data['img_path'], 1 + if self.img_mode != 'GRAY' else 0) + if self.img_mode == 'RGB': + im = cv2.cvtColor(im, cv2.COLOR_BGR2RGB) + data['img'] = im + data['shape'] = [im.shape[0], im.shape[1]] + data = self.apply_pre_processes(data) + + if self.transform: + data['img'] = self.transform(data['img']) + data['text_polys'] = data['text_polys'].tolist() + if len(self.filter_keys): + data_dict = {} + for k, v in data.items(): + if k not in self.filter_keys: + data_dict[k] = v + return data_dict + else: + return data + except: + return self.__getitem__(np.random.randint(self.__len__())) + + def __len__(self): + return len(self.data_list) diff --git a/benchmark/PaddleOCR_DBNet/base/base_trainer.py b/benchmark/PaddleOCR_DBNet/base/base_trainer.py new file mode 100644 index 00000000..82c308d3 --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/base/base_trainer.py @@ -0,0 +1,250 @@ +# -*- coding: utf-8 -*- +# @Time : 2019/8/23 21:50 +# @Author : zhoujun + +import os +import pathlib +import shutil +from pprint import pformat + +import anyconfig +import paddle +import numpy as np +import random +from paddle.jit import to_static +from paddle.static import InputSpec + +from utils import setup_logger + + +class BaseTrainer: + def __init__(self, + config, + model, + criterion, + train_loader, + validate_loader, + metric_cls, + post_process=None): + config['trainer']['output_dir'] = os.path.join( + str(pathlib.Path(os.path.abspath(__name__)).parent), + config['trainer']['output_dir']) + config['name'] = config['name'] + '_' + model.name + self.save_dir = config['trainer']['output_dir'] + self.checkpoint_dir = os.path.join(self.save_dir, 'checkpoint') + + os.makedirs(self.checkpoint_dir, exist_ok=True) + + self.global_step = 0 + self.start_epoch = 0 + self.config = config + self.criterion = criterion + # logger and tensorboard + self.visualdl_enable = self.config['trainer'].get('visual_dl', False) + self.epochs = self.config['trainer']['epochs'] + self.log_iter = self.config['trainer']['log_iter'] + if paddle.distributed.get_rank() == 0: + anyconfig.dump(config, os.path.join(self.save_dir, 'config.yaml')) + self.logger = setup_logger(os.path.join(self.save_dir, 'train.log')) + self.logger_info(pformat(self.config)) + + self.model = self.apply_to_static(model) + + # device + if paddle.device.cuda.device_count( + ) > 0 and paddle.device.is_compiled_with_cuda(): + self.with_cuda = True + random.seed(self.config['trainer']['seed']) + np.random.seed(self.config['trainer']['seed']) + paddle.seed(self.config['trainer']['seed']) + else: + self.with_cuda = False + self.logger_info('train with and paddle {}'.format(paddle.__version__)) + # metrics + self.metrics = { + 'recall': 0, + 'precision': 0, + 'hmean': 0, + 'train_loss': float('inf'), + 'best_model_epoch': 0 + } + + self.train_loader = train_loader + if validate_loader is not None: + assert post_process is not None and metric_cls is not None + self.validate_loader = validate_loader + self.post_process = post_process + self.metric_cls = metric_cls + self.train_loader_len = len(train_loader) + + if self.validate_loader is not None: + self.logger_info( + 'train dataset has {} samples,{} in dataloader, validate dataset has {} samples,{} in dataloader'. + format( + len(self.train_loader.dataset), self.train_loader_len, + len(self.validate_loader.dataset), + len(self.validate_loader))) + else: + self.logger_info( + 'train dataset has {} samples,{} in dataloader'.format( + len(self.train_loader.dataset), self.train_loader_len)) + + self._initialize_scheduler() + + self._initialize_optimizer() + + # resume or finetune + if self.config['trainer']['resume_checkpoint'] != '': + self._load_checkpoint( + self.config['trainer']['resume_checkpoint'], resume=True) + elif self.config['trainer']['finetune_checkpoint'] != '': + self._load_checkpoint( + self.config['trainer']['finetune_checkpoint'], resume=False) + + if self.visualdl_enable and paddle.distributed.get_rank() == 0: + from visualdl import LogWriter + self.writer = LogWriter(self.save_dir) + + # 混合精度训练 + self.amp = self.config.get('amp', None) + if self.amp == 'None': + self.amp = None + if self.amp: + self.amp['scaler'] = paddle.amp.GradScaler( + init_loss_scaling=self.amp.get("scale_loss", 1024), + use_dynamic_loss_scaling=self.amp.get( + 'use_dynamic_loss_scaling', True)) + self.model, self.optimizer = paddle.amp.decorate( + models=self.model, + optimizers=self.optimizer, + level=self.amp.get('amp_level', 'O2')) + + # 分布式训练 + if paddle.device.cuda.device_count() > 1: + self.model = paddle.DataParallel(self.model) + # make inverse Normalize + self.UN_Normalize = False + for t in self.config['dataset']['train']['dataset']['args'][ + 'transforms']: + if t['type'] == 'Normalize': + self.normalize_mean = t['args']['mean'] + self.normalize_std = t['args']['std'] + self.UN_Normalize = True + + def apply_to_static(self, model): + support_to_static = self.config['trainer'].get('to_static', False) + if support_to_static: + specs = None + print('static') + specs = [InputSpec([None, 3, -1, -1])] + model = to_static(model, input_spec=specs) + self.logger_info( + "Successfully to apply @to_static with specs: {}".format(specs)) + return model + + def train(self): + """ + Full training logic + """ + for epoch in range(self.start_epoch + 1, self.epochs + 1): + self.epoch_result = self._train_epoch(epoch) + self._on_epoch_finish() + if paddle.distributed.get_rank() == 0 and self.visualdl_enable: + self.writer.close() + self._on_train_finish() + + def _train_epoch(self, epoch): + """ + Training logic for an epoch + + :param epoch: Current epoch number + """ + raise NotImplementedError + + def _eval(self, epoch): + """ + eval logic for an epoch + + :param epoch: Current epoch number + """ + raise NotImplementedError + + def _on_epoch_finish(self): + raise NotImplementedError + + def _on_train_finish(self): + raise NotImplementedError + + def _save_checkpoint(self, epoch, file_name): + """ + Saving checkpoints + + :param epoch: current epoch number + :param log: logging information of the epoch + :param save_best: if True, rename the saved checkpoint to 'model_best.pth.tar' + """ + state_dict = self.model.state_dict() + state = { + 'epoch': epoch, + 'global_step': self.global_step, + 'state_dict': state_dict, + 'optimizer': self.optimizer.state_dict(), + 'config': self.config, + 'metrics': self.metrics + } + filename = os.path.join(self.checkpoint_dir, file_name) + paddle.save(state, filename) + + def _load_checkpoint(self, checkpoint_path, resume): + """ + Resume from saved checkpoints + :param checkpoint_path: Checkpoint path to be resumed + """ + self.logger_info("Loading checkpoint: {} ...".format(checkpoint_path)) + checkpoint = paddle.load(checkpoint_path) + self.model.set_state_dict(checkpoint['state_dict']) + if resume: + self.global_step = checkpoint['global_step'] + self.start_epoch = checkpoint['epoch'] + self.config['lr_scheduler']['args']['last_epoch'] = self.start_epoch + # self.scheduler.load_state_dict(checkpoint['scheduler']) + self.optimizer.set_state_dict(checkpoint['optimizer']) + if 'metrics' in checkpoint: + self.metrics = checkpoint['metrics'] + self.logger_info("resume from checkpoint {} (epoch {})".format( + checkpoint_path, self.start_epoch)) + else: + self.logger_info("finetune from checkpoint {}".format( + checkpoint_path)) + + def _initialize(self, name, module, *args, **kwargs): + module_name = self.config[name]['type'] + module_args = self.config[name].get('args', {}) + assert all([k not in module_args for k in kwargs + ]), 'Overwriting kwargs given in config file is not allowed' + module_args.update(kwargs) + return getattr(module, module_name)(*args, **module_args) + + def _initialize_scheduler(self): + self.lr_scheduler = self._initialize('lr_scheduler', + paddle.optimizer.lr) + + def _initialize_optimizer(self): + self.optimizer = self._initialize( + 'optimizer', + paddle.optimizer, + parameters=self.model.parameters(), + learning_rate=self.lr_scheduler) + + def inverse_normalize(self, batch_img): + if self.UN_Normalize: + batch_img[:, 0, :, :] = batch_img[:, 0, :, :] * self.normalize_std[ + 0] + self.normalize_mean[0] + batch_img[:, 1, :, :] = batch_img[:, 1, :, :] * self.normalize_std[ + 1] + self.normalize_mean[1] + batch_img[:, 2, :, :] = batch_img[:, 2, :, :] * self.normalize_std[ + 2] + self.normalize_mean[2] + + def logger_info(self, s): + if paddle.distributed.get_rank() == 0: + self.logger.info(s) diff --git a/benchmark/PaddleOCR_DBNet/config/SynthText.yaml b/benchmark/PaddleOCR_DBNet/config/SynthText.yaml new file mode 100644 index 00000000..61d5da7d --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/config/SynthText.yaml @@ -0,0 +1,40 @@ +name: DBNet +dataset: + train: + dataset: + type: SynthTextDataset # 数据集类型 + args: + data_path: ''# SynthTextDataset 根目录 + pre_processes: # 数据的预处理过程,包含augment和标签制作 + - type: IaaAugment # 使用imgaug进行变换 + args: + - {'type':Fliplr, 'args':{'p':0.5}} + - {'type': Affine, 'args':{'rotate':[-10,10]}} + - {'type':Resize,'args':{'size':[0.5,3]}} + - type: EastRandomCropData + args: + size: [640,640] + max_tries: 50 + keep_ratio: true + - type: MakeBorderMap + args: + shrink_ratio: 0.4 + - type: MakeShrinkMap + args: + shrink_ratio: 0.4 + min_text_size: 8 + transforms: # 对图片进行的变换方式 + - type: ToTensor + args: {} + - type: Normalize + args: + mean: [0.485, 0.456, 0.406] + std: [0.229, 0.224, 0.225] + img_mode: RGB + filter_keys: ['img_path','img_name','text_polys','texts','ignore_tags','shape'] # 返回数据之前,从数据字典里删除的key + ignore_tags: ['*', '###'] + loader: + batch_size: 1 + shuffle: true + num_workers: 0 + collate_fn: '' \ No newline at end of file diff --git a/benchmark/PaddleOCR_DBNet/config/SynthText_resnet18_FPN_DBhead_polyLR.yaml b/benchmark/PaddleOCR_DBNet/config/SynthText_resnet18_FPN_DBhead_polyLR.yaml new file mode 100644 index 00000000..a665e94a --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/config/SynthText_resnet18_FPN_DBhead_polyLR.yaml @@ -0,0 +1,65 @@ +name: DBNet +base: ['config/SynthText.yaml'] +arch: + type: Model + backbone: + type: resnet18 + pretrained: true + neck: + type: FPN + inner_channels: 256 + head: + type: DBHead + out_channels: 2 + k: 50 +post_processing: + type: SegDetectorRepresenter + args: + thresh: 0.3 + box_thresh: 0.7 + max_candidates: 1000 + unclip_ratio: 1.5 # from paper +metric: + type: QuadMetric + args: + is_output_polygon: false +loss: + type: DBLoss + alpha: 1 + beta: 10 + ohem_ratio: 3 +optimizer: + type: Adam + args: + lr: 0.001 + weight_decay: 0 + amsgrad: true +lr_scheduler: + type: WarmupPolyLR + args: + warmup_epoch: 3 +trainer: + seed: 2 + epochs: 1200 + log_iter: 10 + show_images_iter: 50 + resume_checkpoint: '' + finetune_checkpoint: '' + output_dir: output + visual_dl: false +amp: + scale_loss: 1024 + amp_level: O2 + custom_white_list: [] + custom_black_list: ['exp', 'sigmoid', 'concat'] +dataset: + train: + dataset: + args: + data_path: ./datasets/SynthText + img_mode: RGB + loader: + batch_size: 2 + shuffle: true + num_workers: 6 + collate_fn: '' \ No newline at end of file diff --git a/benchmark/PaddleOCR_DBNet/config/icdar2015.yaml b/benchmark/PaddleOCR_DBNet/config/icdar2015.yaml new file mode 100644 index 00000000..4551b14b --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/config/icdar2015.yaml @@ -0,0 +1,69 @@ +name: DBNet +dataset: + train: + dataset: + type: ICDAR2015Dataset # 数据集类型 + args: + data_path: # 一个存放 img_path \t gt_path的文件 + - '' + pre_processes: # 数据的预处理过程,包含augment和标签制作 + - type: IaaAugment # 使用imgaug进行变换 + args: + - {'type':Fliplr, 'args':{'p':0.5}} + - {'type': Affine, 'args':{'rotate':[-10,10]}} + - {'type':Resize,'args':{'size':[0.5,3]}} + - type: EastRandomCropData + args: + size: [640,640] + max_tries: 50 + keep_ratio: true + - type: MakeBorderMap + args: + shrink_ratio: 0.4 + thresh_min: 0.3 + thresh_max: 0.7 + - type: MakeShrinkMap + args: + shrink_ratio: 0.4 + min_text_size: 8 + transforms: # 对图片进行的变换方式 + - type: ToTensor + args: {} + - type: Normalize + args: + mean: [0.485, 0.456, 0.406] + std: [0.229, 0.224, 0.225] + img_mode: RGB + filter_keys: [img_path,img_name,text_polys,texts,ignore_tags,shape] # 返回数据之前,从数据字典里删除的key + ignore_tags: ['*', '###'] + loader: + batch_size: 1 + shuffle: true + num_workers: 0 + collate_fn: '' + validate: + dataset: + type: ICDAR2015Dataset + args: + data_path: + - '' + pre_processes: + - type: ResizeShortSize + args: + short_size: 736 + resize_text_polys: false + transforms: + - type: ToTensor + args: {} + - type: Normalize + args: + mean: [0.485, 0.456, 0.406] + std: [0.229, 0.224, 0.225] + img_mode: RGB + filter_keys: [] + ignore_tags: ['*', '###'] + loader: + batch_size: 1 + shuffle: true + num_workers: 0 + collate_fn: ICDARCollectFN \ No newline at end of file diff --git a/benchmark/PaddleOCR_DBNet/config/icdar2015_dcn_resnet18_FPN_DBhead_polyLR.yaml b/benchmark/PaddleOCR_DBNet/config/icdar2015_dcn_resnet18_FPN_DBhead_polyLR.yaml new file mode 100644 index 00000000..608ef42c --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/config/icdar2015_dcn_resnet18_FPN_DBhead_polyLR.yaml @@ -0,0 +1,82 @@ +name: DBNet +base: ['config/icdar2015.yaml'] +arch: + type: Model + backbone: + type: deformable_resnet18 + pretrained: true + neck: + type: FPN + inner_channels: 256 + head: + type: DBHead + out_channels: 2 + k: 50 +post_processing: + type: SegDetectorRepresenter + args: + thresh: 0.3 + box_thresh: 0.7 + max_candidates: 1000 + unclip_ratio: 1.5 # from paper +metric: + type: QuadMetric + args: + is_output_polygon: false +loss: + type: DBLoss + alpha: 1 + beta: 10 + ohem_ratio: 3 +optimizer: + type: Adam + args: + lr: 0.001 + weight_decay: 0 + amsgrad: true +lr_scheduler: + type: WarmupPolyLR + args: + warmup_epoch: 3 +trainer: + seed: 2 + epochs: 1200 + log_iter: 10 + show_images_iter: 50 + resume_checkpoint: '' + finetune_checkpoint: '' + output_dir: output + visual_dl: false +amp: + scale_loss: 1024 + amp_level: O2 + custom_white_list: [] + custom_black_list: ['exp', 'sigmoid', 'concat'] +dataset: + train: + dataset: + args: + data_path: + - ./datasets/train.txt + img_mode: RGB + loader: + batch_size: 1 + shuffle: true + num_workers: 6 + collate_fn: '' + validate: + dataset: + args: + data_path: + - ./datasets/test.txt + pre_processes: + - type: ResizeShortSize + args: + short_size: 736 + resize_text_polys: false + img_mode: RGB + loader: + batch_size: 1 + shuffle: true + num_workers: 6 + collate_fn: ICDARCollectFN \ No newline at end of file diff --git a/benchmark/PaddleOCR_DBNet/config/icdar2015_resnet18_FPN_DBhead_polyLR.yaml b/benchmark/PaddleOCR_DBNet/config/icdar2015_resnet18_FPN_DBhead_polyLR.yaml new file mode 100644 index 00000000..62c392b9 --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/config/icdar2015_resnet18_FPN_DBhead_polyLR.yaml @@ -0,0 +1,82 @@ +name: DBNet +base: ['config/icdar2015.yaml'] +arch: + type: Model + backbone: + type: resnet18 + pretrained: true + neck: + type: FPN + inner_channels: 256 + head: + type: DBHead + out_channels: 2 + k: 50 +post_processing: + type: SegDetectorRepresenter + args: + thresh: 0.3 + box_thresh: 0.7 + max_candidates: 1000 + unclip_ratio: 1.5 # from paper +metric: + type: QuadMetric + args: + is_output_polygon: false +loss: + type: DBLoss + alpha: 1 + beta: 10 + ohem_ratio: 3 +optimizer: + type: Adam + args: + lr: 0.001 + weight_decay: 0 + amsgrad: true +lr_scheduler: + type: WarmupPolyLR + args: + warmup_epoch: 3 +trainer: + seed: 2 + epochs: 1200 + log_iter: 10 + show_images_iter: 50 + resume_checkpoint: '' + finetune_checkpoint: '' + output_dir: output + visual_dl: false +amp: + scale_loss: 1024 + amp_level: O2 + custom_white_list: [] + custom_black_list: ['exp', 'sigmoid', 'concat'] +dataset: + train: + dataset: + args: + data_path: + - ./datasets/train.txt + img_mode: RGB + loader: + batch_size: 1 + shuffle: true + num_workers: 6 + collate_fn: '' + validate: + dataset: + args: + data_path: + - ./datasets/test.txt + pre_processes: + - type: ResizeShortSize + args: + short_size: 736 + resize_text_polys: false + img_mode: RGB + loader: + batch_size: 1 + shuffle: true + num_workers: 6 + collate_fn: ICDARCollectFN diff --git a/benchmark/PaddleOCR_DBNet/config/icdar2015_resnet18_FPN_DBhead_polyLR_finetune.yaml b/benchmark/PaddleOCR_DBNet/config/icdar2015_resnet18_FPN_DBhead_polyLR_finetune.yaml new file mode 100644 index 00000000..9b018d5c --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/config/icdar2015_resnet18_FPN_DBhead_polyLR_finetune.yaml @@ -0,0 +1,83 @@ +name: DBNet +base: ['config/icdar2015.yaml'] +arch: + type: Model + backbone: + type: resnet18 + pretrained: true + neck: + type: FPN + inner_channels: 256 + head: + type: DBHead + out_channels: 2 + k: 50 +post_processing: + type: SegDetectorRepresenter + args: + thresh: 0.3 + box_thresh: 0.7 + max_candidates: 1000 + unclip_ratio: 1.5 # from paper +metric: + type: QuadMetric + args: + is_output_polygon: false +loss: + type: DBLoss + alpha: 1 + beta: 10 + ohem_ratio: 3 +optimizer: + type: Adam + args: + lr: 0.001 + weight_decay: 0 + amsgrad: true +lr_scheduler: + type: StepLR + args: + step_size: 10 + gama: 0.8 +trainer: + seed: 2 + epochs: 500 + log_iter: 10 + show_images_iter: 50 + resume_checkpoint: '' + finetune_checkpoint: '' + output_dir: output + visual_dl: false +amp: + scale_loss: 1024 + amp_level: O2 + custom_white_list: [] + custom_black_list: ['exp', 'sigmoid', 'concat'] +dataset: + train: + dataset: + args: + data_path: + - ./datasets/train.txt + img_mode: RGB + loader: + batch_size: 1 + shuffle: true + num_workers: 6 + collate_fn: '' + validate: + dataset: + args: + data_path: + - ./datasets/test.txt + pre_processes: + - type: ResizeShortSize + args: + short_size: 736 + resize_text_polys: false + img_mode: RGB + loader: + batch_size: 1 + shuffle: true + num_workers: 6 + collate_fn: ICDARCollectFN diff --git a/benchmark/PaddleOCR_DBNet/config/icdar2015_resnet50_FPN_DBhead_polyLR.yaml b/benchmark/PaddleOCR_DBNet/config/icdar2015_resnet50_FPN_DBhead_polyLR.yaml new file mode 100644 index 00000000..2a870fd7 --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/config/icdar2015_resnet50_FPN_DBhead_polyLR.yaml @@ -0,0 +1,79 @@ +name: DBNet +base: ['config/icdar2015.yaml'] +arch: + type: Model + backbone: + type: resnet50 + pretrained: true + neck: + type: FPN + inner_channels: 256 + head: + type: DBHead + out_channels: 2 + k: 50 +post_processing: + type: SegDetectorRepresenter + args: + thresh: 0.3 + box_thresh: 0.7 + max_candidates: 1000 + unclip_ratio: 1.5 # from paper +metric: + type: QuadMetric + args: + is_output_polygon: false +loss: + type: DBLoss + alpha: 1 + beta: 10 + ohem_ratio: 3 +optimizer: + type: Adam +lr_scheduler: + type: Polynomial + args: + learning_rate: 0.001 + warmup_epoch: 3 +trainer: + seed: 2 + epochs: 1200 + log_iter: 10 + show_images_iter: 50 + resume_checkpoint: '' + finetune_checkpoint: '' + output_dir: output/fp16_o2 + visual_dl: false +amp: + scale_loss: 1024 + amp_level: O2 + custom_white_list: [] + custom_black_list: ['exp', 'sigmoid', 'concat'] +dataset: + train: + dataset: + args: + data_path: + - ./datasets/train.txt + img_mode: RGB + loader: + batch_size: 16 + shuffle: true + num_workers: 6 + collate_fn: '' + validate: + dataset: + args: + data_path: + - ./datasets/test.txt + pre_processes: + - type: ResizeShortSize + args: + short_size: 736 + resize_text_polys: false + img_mode: RGB + loader: + batch_size: 1 + shuffle: true + num_workers: 6 + collate_fn: ICDARCollectFN diff --git a/benchmark/PaddleOCR_DBNet/config/open_dataset.yaml b/benchmark/PaddleOCR_DBNet/config/open_dataset.yaml new file mode 100644 index 00000000..97267586 --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/config/open_dataset.yaml @@ -0,0 +1,73 @@ +name: DBNet +dataset: + train: + dataset: + type: DetDataset # 数据集类型 + args: + data_path: # 一个存放 img_path \t gt_path的文件 + - '' + pre_processes: # 数据的预处理过程,包含augment和标签制作 + - type: IaaAugment # 使用imgaug进行变换 + args: + - {'type':Fliplr, 'args':{'p':0.5}} + - {'type': Affine, 'args':{'rotate':[-10,10]}} + - {'type':Resize,'args':{'size':[0.5,3]}} + - type: EastRandomCropData + args: + size: [640,640] + max_tries: 50 + keep_ratio: true + - type: MakeBorderMap + args: + shrink_ratio: 0.4 + thresh_min: 0.3 + thresh_max: 0.7 + - type: MakeShrinkMap + args: + shrink_ratio: 0.4 + min_text_size: 8 + transforms: # 对图片进行的变换方式 + - type: ToTensor + args: {} + - type: Normalize + args: + mean: [0.485, 0.456, 0.406] + std: [0.229, 0.224, 0.225] + img_mode: RGB + load_char_annotation: false + expand_one_char: false + filter_keys: [img_path,img_name,text_polys,texts,ignore_tags,shape] # 返回数据之前,从数据字典里删除的key + ignore_tags: ['*', '###'] + loader: + batch_size: 1 + shuffle: true + num_workers: 0 + collate_fn: '' + validate: + dataset: + type: DetDataset + args: + data_path: + - '' + pre_processes: + - type: ResizeShortSize + args: + short_size: 736 + resize_text_polys: false + transforms: + - type: ToTensor + args: {} + - type: Normalize + args: + mean: [0.485, 0.456, 0.406] + std: [0.229, 0.224, 0.225] + img_mode: RGB + load_char_annotation: false # 是否加载字符级标注 + expand_one_char: false # 是否对只有一个字符的框进行宽度扩充,扩充后w = w+h + filter_keys: [] + ignore_tags: ['*', '###'] + loader: + batch_size: 1 + shuffle: true + num_workers: 0 + collate_fn: ICDARCollectFN \ No newline at end of file diff --git a/benchmark/PaddleOCR_DBNet/config/open_dataset_dcn_resnet50_FPN_DBhead_polyLR.yaml b/benchmark/PaddleOCR_DBNet/config/open_dataset_dcn_resnet50_FPN_DBhead_polyLR.yaml new file mode 100644 index 00000000..6c817387 --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/config/open_dataset_dcn_resnet50_FPN_DBhead_polyLR.yaml @@ -0,0 +1,86 @@ +name: DBNet +base: ['config/open_dataset.yaml'] +arch: + type: Model + backbone: + type: deformable_resnet18 + pretrained: true + neck: + type: FPN + inner_channels: 256 + head: + type: DBHead + out_channels: 2 + k: 50 +post_processing: + type: SegDetectorRepresenter + args: + thresh: 0.3 + box_thresh: 0.7 + max_candidates: 1000 + unclip_ratio: 1.5 # from paper +metric: + type: QuadMetric + args: + is_output_polygon: false +loss: + type: DBLoss + alpha: 1 + beta: 10 + ohem_ratio: 3 +optimizer: + type: Adam + args: + lr: 0.001 + weight_decay: 0 + amsgrad: true +lr_scheduler: + type: WarmupPolyLR + args: + warmup_epoch: 3 +trainer: + seed: 2 + epochs: 1200 + log_iter: 1 + show_images_iter: 1 + resume_checkpoint: '' + finetune_checkpoint: '' + output_dir: output + visual_dl: false +amp: + scale_loss: 1024 + amp_level: O2 + custom_white_list: [] + custom_black_list: ['exp', 'sigmoid', 'concat'] +dataset: + train: + dataset: + args: + data_path: + - ./datasets/train.json + img_mode: RGB + load_char_annotation: false + expand_one_char: false + loader: + batch_size: 2 + shuffle: true + num_workers: 6 + collate_fn: '' + validate: + dataset: + args: + data_path: + - ./datasets/test.json + pre_processes: + - type: ResizeShortSize + args: + short_size: 736 + resize_text_polys: false + img_mode: RGB + load_char_annotation: false + expand_one_char: false + loader: + batch_size: 1 + shuffle: true + num_workers: 6 + collate_fn: ICDARCollectFN diff --git a/benchmark/PaddleOCR_DBNet/config/open_dataset_resnest50_FPN_DBhead_polyLR.yaml b/benchmark/PaddleOCR_DBNet/config/open_dataset_resnest50_FPN_DBhead_polyLR.yaml new file mode 100644 index 00000000..d47ab06e --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/config/open_dataset_resnest50_FPN_DBhead_polyLR.yaml @@ -0,0 +1,86 @@ +name: DBNet +base: ['config/open_dataset.yaml'] +arch: + type: Model + backbone: + type: resnest50 + pretrained: true + neck: + type: FPN + inner_channels: 256 + head: + type: DBHead + out_channels: 2 + k: 50 +post_processing: + type: SegDetectorRepresenter + args: + thresh: 0.3 + box_thresh: 0.7 + max_candidates: 1000 + unclip_ratio: 1.5 # from paper +metric: + type: QuadMetric + args: + is_output_polygon: false +loss: + type: DBLoss + alpha: 1 + beta: 10 + ohem_ratio: 3 +optimizer: + type: Adam + args: + lr: 0.001 + weight_decay: 0 + amsgrad: true +lr_scheduler: + type: WarmupPolyLR + args: + warmup_epoch: 3 +trainer: + seed: 2 + epochs: 1200 + log_iter: 1 + show_images_iter: 1 + resume_checkpoint: '' + finetune_checkpoint: '' + output_dir: output + visual_dl: false +amp: + scale_loss: 1024 + amp_level: O2 + custom_white_list: [] + custom_black_list: ['exp', 'sigmoid', 'concat'] +dataset: + train: + dataset: + args: + data_path: + - ./datasets/train.json + img_mode: RGB + load_char_annotation: false + expand_one_char: false + loader: + batch_size: 2 + shuffle: true + num_workers: 6 + collate_fn: '' + validate: + dataset: + args: + data_path: + - ./datasets/test.json + pre_processes: + - type: ResizeShortSize + args: + short_size: 736 + resize_text_polys: false + img_mode: RGB + load_char_annotation: false + expand_one_char: false + loader: + batch_size: 1 + shuffle: true + num_workers: 6 + collate_fn: ICDARCollectFN diff --git a/benchmark/PaddleOCR_DBNet/config/open_dataset_resnet18_FPN_DBhead_polyLR.yaml b/benchmark/PaddleOCR_DBNet/config/open_dataset_resnet18_FPN_DBhead_polyLR.yaml new file mode 100644 index 00000000..ff16ddb2 --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/config/open_dataset_resnet18_FPN_DBhead_polyLR.yaml @@ -0,0 +1,93 @@ +name: DBNet +base: ['config/open_dataset.yaml'] +arch: + type: Model + backbone: + type: resnet18 + pretrained: true + neck: + type: FPN + inner_channels: 256 + head: + type: DBHead + out_channels: 2 + k: 50 +post_processing: + type: SegDetectorRepresenter + args: + thresh: 0.3 + box_thresh: 0.7 + max_candidates: 1000 + unclip_ratio: 1.5 # from paper +metric: + type: QuadMetric + args: + is_output_polygon: false +loss: + type: DBLoss + alpha: 1 + beta: 10 + ohem_ratio: 3 +optimizer: + type: Adam + args: + lr: 0.001 + weight_decay: 0 + amsgrad: true +lr_scheduler: + type: WarmupPolyLR + args: + warmup_epoch: 3 +trainer: + seed: 2 + epochs: 1200 + log_iter: 1 + show_images_iter: 1 + resume_checkpoint: '' + finetune_checkpoint: '' + output_dir: output + visual_dl: false +amp: + scale_loss: 1024 + amp_level: O2 + custom_white_list: [] + custom_black_list: ['exp', 'sigmoid', 'concat'] +dataset: + train: + dataset: + args: + data_path: + - ./datasets/train.json + transforms: # 对图片进行的变换方式 + - type: ToTensor + args: {} + - type: Normalize + args: + mean: [0.485, 0.456, 0.406] + std: [0.229, 0.224, 0.225] + img_mode: RGB + load_char_annotation: false + expand_one_char: false + loader: + batch_size: 2 + shuffle: true + num_workers: 6 + collate_fn: '' + validate: + dataset: + args: + data_path: + - ./datasets/test.json + pre_processes: + - type: ResizeShortSize + args: + short_size: 736 + resize_text_polys: false + img_mode: RGB + load_char_annotation: false + expand_one_char: false + loader: + batch_size: 1 + shuffle: true + num_workers: 6 + collate_fn: ICDARCollectFN diff --git a/benchmark/PaddleOCR_DBNet/data_loader/__init__.py b/benchmark/PaddleOCR_DBNet/data_loader/__init__.py new file mode 100644 index 00000000..afc6e56b --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/data_loader/__init__.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +# @Time : 2019/8/23 21:52 +# @Author : zhoujun +import copy + +import PIL +import numpy as np +import paddle +from paddle.io import DataLoader, DistributedBatchSampler, BatchSampler + +from paddle.vision import transforms + + +def get_dataset(data_path, module_name, transform, dataset_args): + """ + 获取训练dataset + :param data_path: dataset文件列表,每个文件内以如下格式存储 ‘path/to/img\tlabel’ + :param module_name: 所使用的自定义dataset名称,目前只支持data_loaders.ImageDataset + :param transform: 该数据集使用的transforms + :param dataset_args: module_name的参数 + :return: 如果data_path列表不为空,返回对于的ConcatDataset对象,否则None + """ + from . import dataset + s_dataset = getattr(dataset, module_name)(transform=transform, + data_path=data_path, + **dataset_args) + return s_dataset + + +def get_transforms(transforms_config): + tr_list = [] + for item in transforms_config: + if 'args' not in item: + args = {} + else: + args = item['args'] + cls = getattr(transforms, item['type'])(**args) + tr_list.append(cls) + tr_list = transforms.Compose(tr_list) + return tr_list + + +class ICDARCollectFN: + def __init__(self, *args, **kwargs): + pass + + def __call__(self, batch): + data_dict = {} + to_tensor_keys = [] + for sample in batch: + for k, v in sample.items(): + if k not in data_dict: + data_dict[k] = [] + if isinstance(v, (np.ndarray, paddle.Tensor, PIL.Image.Image)): + if k not in to_tensor_keys: + to_tensor_keys.append(k) + data_dict[k].append(v) + for k in to_tensor_keys: + data_dict[k] = paddle.stack(data_dict[k], 0) + return data_dict + + +def get_dataloader(module_config, distributed=False): + if module_config is None: + return None + config = copy.deepcopy(module_config) + dataset_args = config['dataset']['args'] + if 'transforms' in dataset_args: + img_transfroms = get_transforms(dataset_args.pop('transforms')) + else: + img_transfroms = None + # 创建数据集 + dataset_name = config['dataset']['type'] + data_path = dataset_args.pop('data_path') + if data_path == None: + return None + + data_path = [x for x in data_path if x is not None] + if len(data_path) == 0: + return None + if 'collate_fn' not in config['loader'] or config['loader'][ + 'collate_fn'] is None or len(config['loader']['collate_fn']) == 0: + config['loader']['collate_fn'] = None + else: + config['loader']['collate_fn'] = eval(config['loader']['collate_fn'])() + + _dataset = get_dataset( + data_path=data_path, + module_name=dataset_name, + transform=img_transfroms, + dataset_args=dataset_args) + sampler = None + if distributed: + # 3)使用DistributedSampler + batch_sampler = DistributedBatchSampler( + dataset=_dataset, + batch_size=config['loader'].pop('batch_size'), + shuffle=config['loader'].pop('shuffle')) + else: + batch_sampler = BatchSampler( + dataset=_dataset, + batch_size=config['loader'].pop('batch_size'), + shuffle=config['loader'].pop('shuffle')) + loader = DataLoader( + dataset=_dataset, batch_sampler=batch_sampler, **config['loader']) + return loader diff --git a/benchmark/PaddleOCR_DBNet/data_loader/dataset.py b/benchmark/PaddleOCR_DBNet/data_loader/dataset.py new file mode 100644 index 00000000..29d3954f --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/data_loader/dataset.py @@ -0,0 +1,181 @@ +# -*- coding: utf-8 -*- +# @Time : 2019/8/23 21:54 +# @Author : zhoujun +import pathlib +import os +import cv2 +import numpy as np +import scipy.io as sio +from tqdm.auto import tqdm + +from base import BaseDataSet +from utils import order_points_clockwise, get_datalist, load, expand_polygon + + +class ICDAR2015Dataset(BaseDataSet): + def __init__(self, + data_path: str, + img_mode, + pre_processes, + filter_keys, + ignore_tags, + transform=None, + **kwargs): + super().__init__(data_path, img_mode, pre_processes, filter_keys, + ignore_tags, transform) + + def load_data(self, data_path: str) -> list: + data_list = get_datalist(data_path) + t_data_list = [] + for img_path, label_path in data_list: + data = self._get_annotation(label_path) + if len(data['text_polys']) > 0: + item = { + 'img_path': img_path, + 'img_name': pathlib.Path(img_path).stem + } + item.update(data) + t_data_list.append(item) + else: + print('there is no suit bbox in {}'.format(label_path)) + return t_data_list + + def _get_annotation(self, label_path: str) -> dict: + boxes = [] + texts = [] + ignores = [] + with open(label_path, encoding='utf-8', mode='r') as f: + for line in f.readlines(): + params = line.strip().strip('\ufeff').strip( + '\xef\xbb\xbf').split(',') + try: + box = order_points_clockwise( + np.array(list(map(float, params[:8]))).reshape(-1, 2)) + if cv2.contourArea(box) > 0: + boxes.append(box) + label = params[8] + texts.append(label) + ignores.append(label in self.ignore_tags) + except: + print('load label failed on {}'.format(label_path)) + data = { + 'text_polys': np.array(boxes), + 'texts': texts, + 'ignore_tags': ignores, + } + return data + + +class DetDataset(BaseDataSet): + def __init__(self, + data_path: str, + img_mode, + pre_processes, + filter_keys, + ignore_tags, + transform=None, + **kwargs): + self.load_char_annotation = kwargs['load_char_annotation'] + self.expand_one_char = kwargs['expand_one_char'] + super().__init__(data_path, img_mode, pre_processes, filter_keys, + ignore_tags, transform) + + def load_data(self, data_path: str) -> list: + """ + 从json文件中读取出 文本行的坐标和gt,字符的坐标和gt + :param data_path: + :return: + """ + data_list = [] + for path in data_path: + content = load(path) + for gt in tqdm( + content['data_list'], desc='read file {}'.format(path)): + img_path = os.path.join(content['data_root'], gt['img_name']) + polygons = [] + texts = [] + illegibility_list = [] + language_list = [] + for annotation in gt['annotations']: + if len(annotation['polygon']) == 0 or len(annotation[ + 'text']) == 0: + continue + if len(annotation['text']) > 1 and self.expand_one_char: + annotation['polygon'] = expand_polygon(annotation[ + 'polygon']) + polygons.append(annotation['polygon']) + texts.append(annotation['text']) + illegibility_list.append(annotation['illegibility']) + language_list.append(annotation['language']) + if self.load_char_annotation: + for char_annotation in annotation['chars']: + if len(char_annotation['polygon']) == 0 or len( + char_annotation['char']) == 0: + continue + polygons.append(char_annotation['polygon']) + texts.append(char_annotation['char']) + illegibility_list.append(char_annotation[ + 'illegibility']) + language_list.append(char_annotation['language']) + data_list.append({ + 'img_path': img_path, + 'img_name': gt['img_name'], + 'text_polys': np.array(polygons), + 'texts': texts, + 'ignore_tags': illegibility_list + }) + return data_list + + +class SynthTextDataset(BaseDataSet): + def __init__(self, + data_path: str, + img_mode, + pre_processes, + filter_keys, + transform=None, + **kwargs): + self.transform = transform + self.dataRoot = pathlib.Path(data_path) + if not self.dataRoot.exists(): + raise FileNotFoundError('Dataset folder is not exist.') + + self.targetFilePath = self.dataRoot / 'gt.mat' + if not self.targetFilePath.exists(): + raise FileExistsError('Target file is not exist.') + targets = {} + sio.loadmat( + self.targetFilePath, + targets, + squeeze_me=True, + struct_as_record=False, + variable_names=['imnames', 'wordBB', 'txt']) + + self.imageNames = targets['imnames'] + self.wordBBoxes = targets['wordBB'] + self.transcripts = targets['txt'] + super().__init__(data_path, img_mode, pre_processes, filter_keys, + transform) + + def load_data(self, data_path: str) -> list: + t_data_list = [] + for imageName, wordBBoxes, texts in zip( + self.imageNames, self.wordBBoxes, self.transcripts): + item = {} + wordBBoxes = np.expand_dims( + wordBBoxes, axis=2) if (wordBBoxes.ndim == 2) else wordBBoxes + _, _, numOfWords = wordBBoxes.shape + text_polys = wordBBoxes.reshape( + [8, numOfWords], order='F').T # num_words * 8 + text_polys = text_polys.reshape(numOfWords, 4, + 2) # num_of_words * 4 * 2 + transcripts = [word for line in texts for word in line.split()] + if numOfWords != len(transcripts): + continue + item['img_path'] = str(self.dataRoot / imageName) + item['img_name'] = (self.dataRoot / imageName).stem + item['text_polys'] = text_polys + item['texts'] = transcripts + item['ignore_tags'] = [x in self.ignore_tags for x in transcripts] + t_data_list.append(item) + return t_data_list diff --git a/benchmark/PaddleOCR_DBNet/data_loader/modules/__init__.py b/benchmark/PaddleOCR_DBNet/data_loader/modules/__init__.py new file mode 100644 index 00000000..bc055dae --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/data_loader/modules/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +# @Time : 2019/12/4 10:53 +# @Author : zhoujun +from .iaa_augment import IaaAugment +from .augment import * +from .random_crop_data import EastRandomCropData, PSERandomCrop +from .make_border_map import MakeBorderMap +from .make_shrink_map import MakeShrinkMap diff --git a/benchmark/PaddleOCR_DBNet/data_loader/modules/augment.py b/benchmark/PaddleOCR_DBNet/data_loader/modules/augment.py new file mode 100644 index 00000000..e81bc123 --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/data_loader/modules/augment.py @@ -0,0 +1,304 @@ +# -*- coding: utf-8 -*- +# @Time : 2019/8/23 21:52 +# @Author : zhoujun + +import math +import numbers +import random + +import cv2 +import numpy as np +from skimage.util import random_noise + + +class RandomNoise: + def __init__(self, random_rate): + self.random_rate = random_rate + + def __call__(self, data: dict): + """ + 对图片加噪声 + :param data: {'img':,'text_polys':,'texts':,'ignore_tags':} + :return: + """ + if random.random() > self.random_rate: + return data + data['img'] = (random_noise( + data['img'], mode='gaussian', clip=True) * 255).astype(im.dtype) + return data + + +class RandomScale: + def __init__(self, scales, random_rate): + """ + :param scales: 尺度 + :param ramdon_rate: 随机系数 + :return: + """ + self.random_rate = random_rate + self.scales = scales + + def __call__(self, data: dict) -> dict: + """ + 从scales中随机选择一个尺度,对图片和文本框进行缩放 + :param data: {'img':,'text_polys':,'texts':,'ignore_tags':} + :return: + """ + if random.random() > self.random_rate: + return data + im = data['img'] + text_polys = data['text_polys'] + + tmp_text_polys = text_polys.copy() + rd_scale = float(np.random.choice(self.scales)) + im = cv2.resize(im, dsize=None, fx=rd_scale, fy=rd_scale) + tmp_text_polys *= rd_scale + + data['img'] = im + data['text_polys'] = tmp_text_polys + return data + + +class RandomRotateImgBox: + def __init__(self, degrees, random_rate, same_size=False): + """ + :param degrees: 角度,可以是一个数值或者list + :param ramdon_rate: 随机系数 + :param same_size: 是否保持和原图一样大 + :return: + """ + if isinstance(degrees, numbers.Number): + if degrees < 0: + raise ValueError( + "If degrees is a single number, it must be positive.") + degrees = (-degrees, degrees) + elif isinstance(degrees, list) or isinstance( + degrees, tuple) or isinstance(degrees, np.ndarray): + if len(degrees) != 2: + raise ValueError( + "If degrees is a sequence, it must be of len 2.") + degrees = degrees + else: + raise Exception( + 'degrees must in Number or list or tuple or np.ndarray') + self.degrees = degrees + self.same_size = same_size + self.random_rate = random_rate + + def __call__(self, data: dict) -> dict: + """ + 从scales中随机选择一个尺度,对图片和文本框进行缩放 + :param data: {'img':,'text_polys':,'texts':,'ignore_tags':} + :return: + """ + if random.random() > self.random_rate: + return data + im = data['img'] + text_polys = data['text_polys'] + + # ---------------------- 旋转图像 ---------------------- + w = im.shape[1] + h = im.shape[0] + angle = np.random.uniform(self.degrees[0], self.degrees[1]) + + if self.same_size: + nw = w + nh = h + else: + # 角度变弧度 + rangle = np.deg2rad(angle) + # 计算旋转之后图像的w, h + nw = (abs(np.sin(rangle) * h) + abs(np.cos(rangle) * w)) + nh = (abs(np.cos(rangle) * h) + abs(np.sin(rangle) * w)) + # 构造仿射矩阵 + rot_mat = cv2.getRotationMatrix2D((nw * 0.5, nh * 0.5), angle, 1) + # 计算原图中心点到新图中心点的偏移量 + rot_move = np.dot(rot_mat, + np.array([(nw - w) * 0.5, (nh - h) * 0.5, 0])) + # 更新仿射矩阵 + rot_mat[0, 2] += rot_move[0] + rot_mat[1, 2] += rot_move[1] + # 仿射变换 + rot_img = cv2.warpAffine( + im, + rot_mat, (int(math.ceil(nw)), int(math.ceil(nh))), + flags=cv2.INTER_LANCZOS4) + + # ---------------------- 矫正bbox坐标 ---------------------- + # rot_mat是最终的旋转矩阵 + # 获取原始bbox的四个中点,然后将这四个点转换到旋转后的坐标系下 + rot_text_polys = list() + for bbox in text_polys: + point1 = np.dot(rot_mat, np.array([bbox[0, 0], bbox[0, 1], 1])) + point2 = np.dot(rot_mat, np.array([bbox[1, 0], bbox[1, 1], 1])) + point3 = np.dot(rot_mat, np.array([bbox[2, 0], bbox[2, 1], 1])) + point4 = np.dot(rot_mat, np.array([bbox[3, 0], bbox[3, 1], 1])) + rot_text_polys.append([point1, point2, point3, point4]) + data['img'] = rot_img + data['text_polys'] = np.array(rot_text_polys) + return data + + +class RandomResize: + def __init__(self, size, random_rate, keep_ratio=False): + """ + :param input_size: resize尺寸,数字或者list的形式,如果为list形式,就是[w,h] + :param ramdon_rate: 随机系数 + :param keep_ratio: 是否保持长宽比 + :return: + """ + if isinstance(size, numbers.Number): + if size < 0: + raise ValueError( + "If input_size is a single number, it must be positive.") + size = (size, size) + elif isinstance(size, list) or isinstance(size, tuple) or isinstance( + size, np.ndarray): + if len(size) != 2: + raise ValueError( + "If input_size is a sequence, it must be of len 2.") + size = (size[0], size[1]) + else: + raise Exception( + 'input_size must in Number or list or tuple or np.ndarray') + self.size = size + self.keep_ratio = keep_ratio + self.random_rate = random_rate + + def __call__(self, data: dict) -> dict: + """ + 从scales中随机选择一个尺度,对图片和文本框进行缩放 + :param data: {'img':,'text_polys':,'texts':,'ignore_tags':} + :return: + """ + if random.random() > self.random_rate: + return data + im = data['img'] + text_polys = data['text_polys'] + + if self.keep_ratio: + # 将图片短边pad到和长边一样 + h, w, c = im.shape + max_h = max(h, self.size[0]) + max_w = max(w, self.size[1]) + im_padded = np.zeros((max_h, max_w, c), dtype=np.uint8) + im_padded[:h, :w] = im.copy() + im = im_padded + text_polys = text_polys.astype(np.float32) + h, w, _ = im.shape + im = cv2.resize(im, self.size) + w_scale = self.size[0] / float(w) + h_scale = self.size[1] / float(h) + text_polys[:, :, 0] *= w_scale + text_polys[:, :, 1] *= h_scale + + data['img'] = im + data['text_polys'] = text_polys + return data + + +def resize_image(img, short_size): + height, width, _ = img.shape + if height < width: + new_height = short_size + new_width = new_height / height * width + else: + new_width = short_size + new_height = new_width / width * height + new_height = int(round(new_height / 32) * 32) + new_width = int(round(new_width / 32) * 32) + resized_img = cv2.resize(img, (new_width, new_height)) + return resized_img, (new_width / width, new_height / height) + + +class ResizeShortSize: + def __init__(self, short_size, resize_text_polys=True): + """ + :param size: resize尺寸,数字或者list的形式,如果为list形式,就是[w,h] + :return: + """ + self.short_size = short_size + self.resize_text_polys = resize_text_polys + + def __call__(self, data: dict) -> dict: + """ + 对图片和文本框进行缩放 + :param data: {'img':,'text_polys':,'texts':,'ignore_tags':} + :return: + """ + im = data['img'] + text_polys = data['text_polys'] + + h, w, _ = im.shape + short_edge = min(h, w) + if short_edge < self.short_size: + # 保证短边 >= short_size + scale = self.short_size / short_edge + im = cv2.resize(im, dsize=None, fx=scale, fy=scale) + scale = (scale, scale) + # im, scale = resize_image(im, self.short_size) + if self.resize_text_polys: + # text_polys *= scale + text_polys[:, 0] *= scale[0] + text_polys[:, 1] *= scale[1] + + data['img'] = im + data['text_polys'] = text_polys + return data + + +class HorizontalFlip: + def __init__(self, random_rate): + """ + + :param random_rate: 随机系数 + """ + self.random_rate = random_rate + + def __call__(self, data: dict) -> dict: + """ + 从scales中随机选择一个尺度,对图片和文本框进行缩放 + :param data: {'img':,'text_polys':,'texts':,'ignore_tags':} + :return: + """ + if random.random() > self.random_rate: + return data + im = data['img'] + text_polys = data['text_polys'] + + flip_text_polys = text_polys.copy() + flip_im = cv2.flip(im, 1) + h, w, _ = flip_im.shape + flip_text_polys[:, :, 0] = w - flip_text_polys[:, :, 0] + + data['img'] = flip_im + data['text_polys'] = flip_text_polys + return data + + +class VerticallFlip: + def __init__(self, random_rate): + """ + + :param random_rate: 随机系数 + """ + self.random_rate = random_rate + + def __call__(self, data: dict) -> dict: + """ + 从scales中随机选择一个尺度,对图片和文本框进行缩放 + :param data: {'img':,'text_polys':,'texts':,'ignore_tags':} + :return: + """ + if random.random() > self.random_rate: + return data + im = data['img'] + text_polys = data['text_polys'] + + flip_text_polys = text_polys.copy() + flip_im = cv2.flip(im, 0) + h, w, _ = flip_im.shape + flip_text_polys[:, :, 1] = h - flip_text_polys[:, :, 1] + data['img'] = flip_im + data['text_polys'] = flip_text_polys + return data diff --git a/benchmark/PaddleOCR_DBNet/data_loader/modules/iaa_augment.py b/benchmark/PaddleOCR_DBNet/data_loader/modules/iaa_augment.py new file mode 100644 index 00000000..1cf891bb --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/data_loader/modules/iaa_augment.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# @Time : 2019/12/4 18:06 +# @Author : zhoujun +import numpy as np +import imgaug +import imgaug.augmenters as iaa + + +class AugmenterBuilder(object): + def __init__(self): + pass + + def build(self, args, root=True): + if args is None or len(args) == 0: + return None + elif isinstance(args, list): + if root: + sequence = [self.build(value, root=False) for value in args] + return iaa.Sequential(sequence) + else: + return getattr( + iaa, + args[0])(* [self.to_tuple_if_list(a) for a in args[1:]]) + elif isinstance(args, dict): + cls = getattr(iaa, args['type']) + return cls(**{ + k: self.to_tuple_if_list(v) + for k, v in args['args'].items() + }) + else: + raise RuntimeError('unknown augmenter arg: ' + str(args)) + + def to_tuple_if_list(self, obj): + if isinstance(obj, list): + return tuple(obj) + return obj + + +class IaaAugment(): + def __init__(self, augmenter_args): + self.augmenter_args = augmenter_args + self.augmenter = AugmenterBuilder().build(self.augmenter_args) + + def __call__(self, data): + image = data['img'] + shape = image.shape + + if self.augmenter: + aug = self.augmenter.to_deterministic() + data['img'] = aug.augment_image(image) + data = self.may_augment_annotation(aug, data, shape) + return data + + def may_augment_annotation(self, aug, data, shape): + if aug is None: + return data + + line_polys = [] + for poly in data['text_polys']: + new_poly = self.may_augment_poly(aug, shape, poly) + line_polys.append(new_poly) + data['text_polys'] = np.array(line_polys) + return data + + def may_augment_poly(self, aug, img_shape, poly): + keypoints = [imgaug.Keypoint(p[0], p[1]) for p in poly] + keypoints = aug.augment_keypoints( + [imgaug.KeypointsOnImage( + keypoints, shape=img_shape)])[0].keypoints + poly = [(p.x, p.y) for p in keypoints] + return poly diff --git a/benchmark/PaddleOCR_DBNet/data_loader/modules/make_border_map.py b/benchmark/PaddleOCR_DBNet/data_loader/modules/make_border_map.py new file mode 100644 index 00000000..2985f3c8 --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/data_loader/modules/make_border_map.py @@ -0,0 +1,143 @@ +import cv2 +import numpy as np +np.seterr(divide='ignore', invalid='ignore') +import pyclipper +from shapely.geometry import Polygon + + +class MakeBorderMap(): + def __init__(self, shrink_ratio=0.4, thresh_min=0.3, thresh_max=0.7): + self.shrink_ratio = shrink_ratio + self.thresh_min = thresh_min + self.thresh_max = thresh_max + + def __call__(self, data: dict) -> dict: + """ + 从scales中随机选择一个尺度,对图片和文本框进行缩放 + :param data: {'img':,'text_polys':,'texts':,'ignore_tags':} + :return: + """ + im = data['img'] + text_polys = data['text_polys'] + ignore_tags = data['ignore_tags'] + + canvas = np.zeros(im.shape[:2], dtype=np.float32) + mask = np.zeros(im.shape[:2], dtype=np.float32) + + for i in range(len(text_polys)): + if ignore_tags[i]: + continue + self.draw_border_map(text_polys[i], canvas, mask=mask) + canvas = canvas * (self.thresh_max - self.thresh_min) + self.thresh_min + + data['threshold_map'] = canvas + data['threshold_mask'] = mask + return data + + def draw_border_map(self, polygon, canvas, mask): + polygon = np.array(polygon) + assert polygon.ndim == 2 + assert polygon.shape[1] == 2 + + polygon_shape = Polygon(polygon) + if polygon_shape.area <= 0: + return + distance = polygon_shape.area * ( + 1 - np.power(self.shrink_ratio, 2)) / polygon_shape.length + subject = [tuple(l) for l in polygon] + padding = pyclipper.PyclipperOffset() + padding.AddPath(subject, pyclipper.JT_ROUND, pyclipper.ET_CLOSEDPOLYGON) + + padded_polygon = np.array(padding.Execute(distance)[0]) + cv2.fillPoly(mask, [padded_polygon.astype(np.int32)], 1.0) + + xmin = padded_polygon[:, 0].min() + xmax = padded_polygon[:, 0].max() + ymin = padded_polygon[:, 1].min() + ymax = padded_polygon[:, 1].max() + width = xmax - xmin + 1 + height = ymax - ymin + 1 + + polygon[:, 0] = polygon[:, 0] - xmin + polygon[:, 1] = polygon[:, 1] - ymin + + xs = np.broadcast_to( + np.linspace( + 0, width - 1, num=width).reshape(1, width), (height, width)) + ys = np.broadcast_to( + np.linspace( + 0, height - 1, num=height).reshape(height, 1), (height, width)) + + distance_map = np.zeros( + (polygon.shape[0], height, width), dtype=np.float32) + for i in range(polygon.shape[0]): + j = (i + 1) % polygon.shape[0] + absolute_distance = self.distance(xs, ys, polygon[i], polygon[j]) + distance_map[i] = np.clip(absolute_distance / distance, 0, 1) + distance_map = distance_map.min(axis=0) + + xmin_valid = min(max(0, xmin), canvas.shape[1] - 1) + xmax_valid = min(max(0, xmax), canvas.shape[1] - 1) + ymin_valid = min(max(0, ymin), canvas.shape[0] - 1) + ymax_valid = min(max(0, ymax), canvas.shape[0] - 1) + canvas[ymin_valid:ymax_valid + 1, xmin_valid:xmax_valid + 1] = np.fmax( + 1 - distance_map[ymin_valid - ymin:ymax_valid - ymax + height, + xmin_valid - xmin:xmax_valid - xmax + width], + canvas[ymin_valid:ymax_valid + 1, xmin_valid:xmax_valid + 1]) + + def distance(self, xs, ys, point_1, point_2): + ''' + compute the distance from point to a line + ys: coordinates in the first axis + xs: coordinates in the second axis + point_1, point_2: (x, y), the end of the line + ''' + height, width = xs.shape[:2] + square_distance_1 = np.square(xs - point_1[0]) + np.square(ys - point_1[ + 1]) + square_distance_2 = np.square(xs - point_2[0]) + np.square(ys - point_2[ + 1]) + square_distance = np.square(point_1[0] - point_2[0]) + np.square( + point_1[1] - point_2[1]) + + cosin = (square_distance - square_distance_1 - square_distance_2) / ( + 2 * np.sqrt(square_distance_1 * square_distance_2)) + square_sin = 1 - np.square(cosin) + square_sin = np.nan_to_num(square_sin) + + result = np.sqrt(square_distance_1 * square_distance_2 * square_sin / + square_distance) + result[cosin < + 0] = np.sqrt(np.fmin(square_distance_1, square_distance_2))[cosin + < 0] + # self.extend_line(point_1, point_2, result) + return result + + def extend_line(self, point_1, point_2, result): + ex_point_1 = (int( + round(point_1[0] + (point_1[0] - point_2[0]) * ( + 1 + self.shrink_ratio))), int( + round(point_1[1] + (point_1[1] - point_2[1]) * ( + 1 + self.shrink_ratio)))) + cv2.line( + result, + tuple(ex_point_1), + tuple(point_1), + 4096.0, + 1, + lineType=cv2.LINE_AA, + shift=0) + ex_point_2 = (int( + round(point_2[0] + (point_2[0] - point_1[0]) * ( + 1 + self.shrink_ratio))), int( + round(point_2[1] + (point_2[1] - point_1[1]) * ( + 1 + self.shrink_ratio)))) + cv2.line( + result, + tuple(ex_point_2), + tuple(point_2), + 4096.0, + 1, + lineType=cv2.LINE_AA, + shift=0) + return ex_point_1, ex_point_2 diff --git a/benchmark/PaddleOCR_DBNet/data_loader/modules/make_shrink_map.py b/benchmark/PaddleOCR_DBNet/data_loader/modules/make_shrink_map.py new file mode 100644 index 00000000..3f268b9d --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/data_loader/modules/make_shrink_map.py @@ -0,0 +1,133 @@ +import numpy as np +import cv2 + + +def shrink_polygon_py(polygon, shrink_ratio): + """ + 对框进行缩放,返回去的比例为1/shrink_ratio 即可 + """ + cx = polygon[:, 0].mean() + cy = polygon[:, 1].mean() + polygon[:, 0] = cx + (polygon[:, 0] - cx) * shrink_ratio + polygon[:, 1] = cy + (polygon[:, 1] - cy) * shrink_ratio + return polygon + + +def shrink_polygon_pyclipper(polygon, shrink_ratio): + from shapely.geometry import Polygon + import pyclipper + polygon_shape = Polygon(polygon) + distance = polygon_shape.area * ( + 1 - np.power(shrink_ratio, 2)) / polygon_shape.length + subject = [tuple(l) for l in polygon] + padding = pyclipper.PyclipperOffset() + padding.AddPath(subject, pyclipper.JT_ROUND, pyclipper.ET_CLOSEDPOLYGON) + shrinked = padding.Execute(-distance) + if shrinked == []: + shrinked = np.array(shrinked) + else: + shrinked = np.array(shrinked[0]).reshape(-1, 2) + return shrinked + + +class MakeShrinkMap(): + r''' + Making binary mask from detection data with ICDAR format. + Typically following the process of class `MakeICDARData`. + ''' + + def __init__(self, + min_text_size=8, + shrink_ratio=0.4, + shrink_type='pyclipper'): + shrink_func_dict = { + 'py': shrink_polygon_py, + 'pyclipper': shrink_polygon_pyclipper + } + self.shrink_func = shrink_func_dict[shrink_type] + self.min_text_size = min_text_size + self.shrink_ratio = shrink_ratio + + def __call__(self, data: dict) -> dict: + """ + 从scales中随机选择一个尺度,对图片和文本框进行缩放 + :param data: {'img':,'text_polys':,'texts':,'ignore_tags':} + :return: + """ + image = data['img'] + text_polys = data['text_polys'] + ignore_tags = data['ignore_tags'] + + h, w = image.shape[:2] + text_polys, ignore_tags = self.validate_polygons(text_polys, + ignore_tags, h, w) + gt = np.zeros((h, w), dtype=np.float32) + mask = np.ones((h, w), dtype=np.float32) + for i in range(len(text_polys)): + polygon = text_polys[i] + height = max(polygon[:, 1]) - min(polygon[:, 1]) + width = max(polygon[:, 0]) - min(polygon[:, 0]) + if ignore_tags[i] or min(height, width) < self.min_text_size: + cv2.fillPoly(mask, + polygon.astype(np.int32)[np.newaxis, :, :], 0) + ignore_tags[i] = True + else: + shrinked = self.shrink_func(polygon, self.shrink_ratio) + if shrinked.size == 0: + cv2.fillPoly(mask, + polygon.astype(np.int32)[np.newaxis, :, :], 0) + ignore_tags[i] = True + continue + cv2.fillPoly(gt, [shrinked.astype(np.int32)], 1) + + data['shrink_map'] = gt + data['shrink_mask'] = mask + return data + + def validate_polygons(self, polygons, ignore_tags, h, w): + ''' + polygons (numpy.array, required): of shape (num_instances, num_points, 2) + ''' + if len(polygons) == 0: + return polygons, ignore_tags + assert len(polygons) == len(ignore_tags) + for polygon in polygons: + polygon[:, 0] = np.clip(polygon[:, 0], 0, w - 1) + polygon[:, 1] = np.clip(polygon[:, 1], 0, h - 1) + + for i in range(len(polygons)): + area = self.polygon_area(polygons[i]) + if abs(area) < 1: + ignore_tags[i] = True + if area > 0: + polygons[i] = polygons[i][::-1, :] + return polygons, ignore_tags + + def polygon_area(self, polygon): + return cv2.contourArea(polygon) + # edge = 0 + # for i in range(polygon.shape[0]): + # next_index = (i + 1) % polygon.shape[0] + # edge += (polygon[next_index, 0] - polygon[i, 0]) * (polygon[next_index, 1] - polygon[i, 1]) + # + # return edge / 2. + + +if __name__ == '__main__': + from shapely.geometry import Polygon + import pyclipper + + polygon = np.array([[0, 0], [100, 10], [100, 100], [10, 90]]) + a = shrink_polygon_py(polygon, 0.4) + print(a) + print(shrink_polygon_py(a, 1 / 0.4)) + b = shrink_polygon_pyclipper(polygon, 0.4) + print(b) + poly = Polygon(b) + distance = poly.area * 1.5 / poly.length + offset = pyclipper.PyclipperOffset() + offset.AddPath(b, pyclipper.JT_ROUND, pyclipper.ET_CLOSEDPOLYGON) + expanded = np.array(offset.Execute(distance)) + bounding_box = cv2.minAreaRect(expanded) + points = cv2.boxPoints(bounding_box) + print(points) diff --git a/benchmark/PaddleOCR_DBNet/data_loader/modules/random_crop_data.py b/benchmark/PaddleOCR_DBNet/data_loader/modules/random_crop_data.py new file mode 100644 index 00000000..fac2e4c0 --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/data_loader/modules/random_crop_data.py @@ -0,0 +1,206 @@ +import random + +import cv2 +import numpy as np + + +# random crop algorithm similar to https://github.com/argman/EAST +class EastRandomCropData(): + def __init__(self, + size=(640, 640), + max_tries=50, + min_crop_side_ratio=0.1, + require_original_image=False, + keep_ratio=True): + self.size = size + self.max_tries = max_tries + self.min_crop_side_ratio = min_crop_side_ratio + self.require_original_image = require_original_image + self.keep_ratio = keep_ratio + + def __call__(self, data: dict) -> dict: + """ + 从scales中随机选择一个尺度,对图片和文本框进行缩放 + :param data: {'img':,'text_polys':,'texts':,'ignore_tags':} + :return: + """ + im = data['img'] + text_polys = data['text_polys'] + ignore_tags = data['ignore_tags'] + texts = data['texts'] + all_care_polys = [ + text_polys[i] for i, tag in enumerate(ignore_tags) if not tag + ] + # 计算crop区域 + crop_x, crop_y, crop_w, crop_h = self.crop_area(im, all_care_polys) + # crop 图片 保持比例填充 + scale_w = self.size[0] / crop_w + scale_h = self.size[1] / crop_h + scale = min(scale_w, scale_h) + h = int(crop_h * scale) + w = int(crop_w * scale) + if self.keep_ratio: + if len(im.shape) == 3: + padimg = np.zeros((self.size[1], self.size[0], im.shape[2]), + im.dtype) + else: + padimg = np.zeros((self.size[1], self.size[0]), im.dtype) + padimg[:h, :w] = cv2.resize( + im[crop_y:crop_y + crop_h, crop_x:crop_x + crop_w], (w, h)) + img = padimg + else: + img = cv2.resize(im[crop_y:crop_y + crop_h, crop_x:crop_x + crop_w], + tuple(self.size)) + # crop 文本框 + text_polys_crop = [] + ignore_tags_crop = [] + texts_crop = [] + for poly, text, tag in zip(text_polys, texts, ignore_tags): + poly = ((poly - (crop_x, crop_y)) * scale).tolist() + if not self.is_poly_outside_rect(poly, 0, 0, w, h): + text_polys_crop.append(poly) + ignore_tags_crop.append(tag) + texts_crop.append(text) + data['img'] = img + data['text_polys'] = np.float32(text_polys_crop) + data['ignore_tags'] = ignore_tags_crop + data['texts'] = texts_crop + return data + + def is_poly_in_rect(self, poly, x, y, w, h): + poly = np.array(poly) + if poly[:, 0].min() < x or poly[:, 0].max() > x + w: + return False + if poly[:, 1].min() < y or poly[:, 1].max() > y + h: + return False + return True + + def is_poly_outside_rect(self, poly, x, y, w, h): + poly = np.array(poly) + if poly[:, 0].max() < x or poly[:, 0].min() > x + w: + return True + if poly[:, 1].max() < y or poly[:, 1].min() > y + h: + return True + return False + + def split_regions(self, axis): + regions = [] + min_axis = 0 + for i in range(1, axis.shape[0]): + if axis[i] != axis[i - 1] + 1: + region = axis[min_axis:i] + min_axis = i + regions.append(region) + return regions + + def random_select(self, axis, max_size): + xx = np.random.choice(axis, size=2) + xmin = np.min(xx) + xmax = np.max(xx) + xmin = np.clip(xmin, 0, max_size - 1) + xmax = np.clip(xmax, 0, max_size - 1) + return xmin, xmax + + def region_wise_random_select(self, regions, max_size): + selected_index = list(np.random.choice(len(regions), 2)) + selected_values = [] + for index in selected_index: + axis = regions[index] + xx = int(np.random.choice(axis, size=1)) + selected_values.append(xx) + xmin = min(selected_values) + xmax = max(selected_values) + return xmin, xmax + + def crop_area(self, im, text_polys): + h, w = im.shape[:2] + h_array = np.zeros(h, dtype=np.int32) + w_array = np.zeros(w, dtype=np.int32) + for points in text_polys: + points = np.round(points, decimals=0).astype(np.int32) + minx = np.min(points[:, 0]) + maxx = np.max(points[:, 0]) + w_array[minx:maxx] = 1 + miny = np.min(points[:, 1]) + maxy = np.max(points[:, 1]) + h_array[miny:maxy] = 1 + # ensure the cropped area not across a text + h_axis = np.where(h_array == 0)[0] + w_axis = np.where(w_array == 0)[0] + + if len(h_axis) == 0 or len(w_axis) == 0: + return 0, 0, w, h + + h_regions = self.split_regions(h_axis) + w_regions = self.split_regions(w_axis) + + for i in range(self.max_tries): + if len(w_regions) > 1: + xmin, xmax = self.region_wise_random_select(w_regions, w) + else: + xmin, xmax = self.random_select(w_axis, w) + if len(h_regions) > 1: + ymin, ymax = self.region_wise_random_select(h_regions, h) + else: + ymin, ymax = self.random_select(h_axis, h) + + if xmax - xmin < self.min_crop_side_ratio * w or ymax - ymin < self.min_crop_side_ratio * h: + # area too small + continue + num_poly_in_rect = 0 + for poly in text_polys: + if not self.is_poly_outside_rect(poly, xmin, ymin, xmax - xmin, + ymax - ymin): + num_poly_in_rect += 1 + break + + if num_poly_in_rect > 0: + return xmin, ymin, xmax - xmin, ymax - ymin + + return 0, 0, w, h + + +class PSERandomCrop(): + def __init__(self, size): + self.size = size + + def __call__(self, data): + imgs = data['imgs'] + + h, w = imgs[0].shape[0:2] + th, tw = self.size + if w == tw and h == th: + return imgs + + # label中存在文本实例,并且按照概率进行裁剪,使用threshold_label_map控制 + if np.max(imgs[2]) > 0 and random.random() > 3 / 8: + # 文本实例的左上角点 + tl = np.min(np.where(imgs[2] > 0), axis=1) - self.size + tl[tl < 0] = 0 + # 文本实例的右下角点 + br = np.max(np.where(imgs[2] > 0), axis=1) - self.size + br[br < 0] = 0 + # 保证选到右下角点时,有足够的距离进行crop + br[0] = min(br[0], h - th) + br[1] = min(br[1], w - tw) + + for _ in range(50000): + i = random.randint(tl[0], br[0]) + j = random.randint(tl[1], br[1]) + # 保证shrink_label_map有文本 + if imgs[1][i:i + th, j:j + tw].sum() <= 0: + continue + else: + break + else: + i = random.randint(0, h - th) + j = random.randint(0, w - tw) + + # return i, j, th, tw + for idx in range(len(imgs)): + if len(imgs[idx].shape) == 3: + imgs[idx] = imgs[idx][i:i + th, j:j + tw, :] + else: + imgs[idx] = imgs[idx][i:i + th, j:j + tw] + data['imgs'] = imgs + return data diff --git a/benchmark/PaddleOCR_DBNet/environment.yml b/benchmark/PaddleOCR_DBNet/environment.yml new file mode 100644 index 00000000..571dbf2a --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/environment.yml @@ -0,0 +1,21 @@ +name: dbnet +channels: + - conda-forge + - defaults +dependencies: + - anyconfig==0.9.10 + - future==0.18.2 + - imgaug==0.4.0 + - matplotlib==3.1.2 + - numpy==1.17.4 + - opencv + - pyclipper + - PyYAML==5.2 + - scikit-image==0.16.2 + - Shapely==1.6.4 + - tensorboard=2 + - tqdm==4.40.1 + - ipython + - pip + - pip: + - polygon3 diff --git a/benchmark/PaddleOCR_DBNet/eval.sh b/benchmark/PaddleOCR_DBNet/eval.sh new file mode 100644 index 00000000..b3bf4681 --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/eval.sh @@ -0,0 +1 @@ +CUDA_VISIBLE_DEVICES=0 python3 tools/eval.py --model_path '' \ No newline at end of file diff --git a/benchmark/PaddleOCR_DBNet/generate_lists.sh b/benchmark/PaddleOCR_DBNet/generate_lists.sh new file mode 100644 index 00000000..84f408c6 --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/generate_lists.sh @@ -0,0 +1,17 @@ +#Only use if your file names of the images and txts are identical +rm ./datasets/train_img.txt +rm ./datasets/train_gt.txt +rm ./datasets/test_img.txt +rm ./datasets/test_gt.txt +rm ./datasets/train.txt +rm ./datasets/test.txt +ls ./datasets/train/img/*.jpg > ./datasets/train_img.txt +ls ./datasets/train/gt/*.txt > ./datasets/train_gt.txt +ls ./datasets/test/img/*.jpg > ./datasets/test_img.txt +ls ./datasets/test/gt/*.txt > ./datasets/test_gt.txt +paste ./datasets/train_img.txt ./datasets/train_gt.txt > ./datasets/train.txt +paste ./datasets/test_img.txt ./datasets/test_gt.txt > ./datasets/test.txt +rm ./datasets/train_img.txt +rm ./datasets/train_gt.txt +rm ./datasets/test_img.txt +rm ./datasets/test_gt.txt diff --git a/benchmark/PaddleOCR_DBNet/imgs/paper/db.jpg b/benchmark/PaddleOCR_DBNet/imgs/paper/db.jpg new file mode 100644 index 0000000000000000000000000000000000000000..aa6c7e9890551abb9aaf39fe76db67cb5588507b GIT binary patch literal 194472 zcmeFZ2|Sc<+dn)~QI<)_7E>XzMp-IMC40zT#8g5E*#~2$2wA5niZa;}Vj^3%$)0`5 zlCcl64l~waw)g7yJooba?)!Z{_xt?c<@vv#XShCd4cB#@$8ny=d7R(ldmQI5e=?^) zM+|ihbU`dEETB8U4~RJpvekjRI)gyQ#vmCG2*eILz{&zT0GzP^haoHLAMd};0-wCZZtdkxAWiEfmkBngFwY-<|s%Tw2y`5_wT>ISP!uNejVJupY;IS zK{mGE2m2w8L+tE_+1c0*a~(d+!3n(B4snCIIJtkH|9;5t+kf8${Bp9hvH!l}|JsZB z4aCE~PxRm|R+dwseLO6zJS@y65Co9)0MHY^JLBJ9Ec;mZA2OnUh$Ipy`6)Xc2xoDaDl^YTl+l$MoORDP{$Xl!b3Y5m^T-v47@aA^4F2!5I{ zGduTdeqnKmyuPuywY@{x-TSQ<3yAgK)cUJtf6|Kw&}$zs2K(85>&3D!5I9(Q_8&N@ zbnxgEGq$_lyr-0(u=8Dgkx^WKNc@~RiT|EY-{E5t=cgsfzg7D~vwuyou>X=~e^u;n zdf`EwfMM?AVdViqL3CPXj5O$fj{oU{|J(-Vkx~d-(quh`TcMB;@7$_Woak2;u?qS1 z_1#8^;B%*(SDyn~p1k?|h>I(y(rx>qjBGbeoN7l?B2P9L8?~mw5^BzQPjej$^6-~8 zTQe8*Ya`rA)b_3k=ge<9lW@6+gC#*ndkQwnJB?~`S3GK(Lz%hU8gAx1oK;gv<;2T9 ztWLCho%@s2(tgOs|M0h?hnm4glbN^<+JM^>Tfc*I)edxTI#|G=;$zOb<7qW9m+kRKsVuM6AhC zPg9U(S))dE8-{EdsT$6jla19P)y`-NG?EDlbQsrWu+3CV{`k7UXQM{?LSTX-k*X9= zW55~$2QhqdIxR>IGtE$FG_rTSUU56j{i6M;1FK0!o zt$sIDOuY%WF&tEozu}hBFy86xZs#qbpl4rLc|V}X^m@z?#gV+MLn;1be{=Eql2Vt; zkyl+!C;8%LSUH1wiew4s7!1EWQf$%T`XvW)%g;;;#v?XgdT=n5c68GAE1A215K{0} z_*VA*#9)@!6tTxPy^cNz&Ex0ub$5)uPEBpZ>D<2N+CJn}ysg4#20I<9Z>GZs`txD) z4Mnm7qmM9JCo6VVs>$xH821XwnM@|gYaw;0=tNMVhFadFU8Cr`xo*rivJh6F#q&JjnrQQv7qNTcisQb0DcS$Q**5sDX zmi30c1$C$(ziJ6-%@{|jk+`v>FW;CTVJ!~IgWCw9e#WJRbT!H=tgH3WLTof4j=yqn z->c5;?e(;PAWOlHmd&6}}YSy_e?eLoQ2R@_U&s}q0 zo5v>)`A>xdiKoIO<5@J4B0#J+kO&`9YGh}M^xHC?xD`gY(XOl`z zTj2bAyw_7FF2&c~cRk(0U7d32rl0uaM3szzp{Hq~p5~q*Pp&kLkZPJ0Sk^CPkE-w3 zuy{G6_%hYQe{NW}uM5FTx#dLCn^ugfk;-k87~12C3#db2mKENDC$7)eBwlcF9Dkd> zHDIQ5J$p_{YQPLFV{m}a%>2fmZN*G?)qAo(1|*Grje4L+k;T+AK_gi+r^}|Gytm(-mk8uZfJF9)+zn@@`sZo2i#*-^7+KuaQu0*WM|UmHO`Ac_iazr$fHk-)!#@r$PjVM$Y^=HJ~v2(0I_)*^D|}o z^`OcXdLBX3!tbJ36z3C9l?#uTP+t$<7kC~gJ>?XWN1GXDf~G1b#WXZO7=#2dL5KS% zROavWD~MoLxnoyRd3FhWffPE`omJJc^8}| z65Z!8F)+TOn$Wz@oQ_}OrjLjzF;LL&-~}OJfYHi^r>M=G9v!WS3+pUz=Kk&4=AJZ2QF!!BneSR5q%!gf2=%(unwR z-Ba(_FTS!*)Ow$Z<3YGGK?j29*)^^On&Y^HP0Znj&RY-t&|tj5euwbTb|Z1e@q&IP z$OTGKXl+78?#}zer>OOQjEP3!=F#F#HY30!G)=sb4#!|7ZJ)g|ij*PZvzlIAgOfnf9`B(1rm=luiT zl0~=cffdE+o@n?CbZpCuu%N!Y(b{YplZ*rVVNZ)<;WH1-&M-mhtX~;H?=~NXe{&5-ss2JP*QuoB z?k?m>x)41ZDM?)mhtowmL{zN zVL)v|y`G@vyHIsd+MCubElnl}LqS!+Xit8j{`|DQf6`>I3+)1U+Z_;tdl_(dTzX6p z8n}*Io1V%9EocL-qaoak37XmbTQjdiP0^@%24ppb2|5vkp${Kxu(*by7+vY-DB-zqr0G-NTOA)Sz|O)OZ#-`S zNnHwz+g_Wf6`Y1&wWQ}a+LU2yR5$0p*30P$%cQpv9_`eYr!#7j)ldX+NEj2ed4_g_ z3HrtXVHjmGPAO3U2X+`E&&;1kWWm}*my|oeO z^u!B#1_8{4e_$=r1alqGunBSJ-slUg{U)Rt>8m%a{@1}pFCjt!4HrTI?>oya{*V0z z{RfqcHh?)8rp9Qm6CmOjUt^%>!!9A) zgL5N>Z4r65}fLrn+2rYatr&*K>iz`xZ%*QMsKwuAulK*^ zBUFL;vEPRN+P{E(^%G4%a{N=HpRYFI(ysXP;7Hyn2ET(BMcict>s8d6(>AR?Gw{KF z@xg>t9dFgT4=+*V+0;ft3%c-;>O>1}62iEymD^fPMf_nDQ~#2YugU?a42>#@%*dT^ zu%mbmh1hHG`kzP2)IK0S7P|EF_T5J;QtFGw{kW;;kP^>Y?AW5Jmgtw7K{*maiGPtrcIeQFfat#*CK51=@JRKk*L#XysT*Kn7PvBkVBB z)x(ZZ@ffLq5a_2Vk}amntGVNgDxzvk3yd?DipMT1$QKn}|MAAm;ap;Zkz%_+hX*VV zH~k&;1x9BNgi&s|L21ZRc+mj^72Rs%(`5;H**_?IAEFZk(C>Z}UR^BROkNzJCoKHe z=RM#bqE0fpP@5;mQPWpGGC|}$Od;v7;|BRV2R@G??f_WK`75Q5szb49#T?vH6oH=# z1?w?-D%Fsd@;(!YL{ML=L~)0hAfn{H3ngS2hx`sDJsK>WAMr!Q;UOau)6nUYeRt(~ zfcPcE;Z~^8H9HBiAb$&E5=PRM9i7BqYG53va7~32h#p?k6A&)k`jv5RaXTq!UBMw} zB!Tz@6_A$3dNO3V7g7SI9Wk1otjCrtP4X0>rtiVSG@v6woELSfgM)63t6p^0E=?$o z*`xJ{)O(%I5-#={-#J=QX1ErRX4G$7@bC?J#DOh-DA3k2JMq`fD6ZJE<9@%F@tAqw zc-9n-BJI*N38py$Bl#F%LRO2|a+mkLG8p+nGKZL*MtIF7Gib+FY;A4MeYoWL;_kkY;x-t^T2gmfi<_SG>HAHuFiJkG(FF|CkSaS;;{h25UrMM&KwH zq=PxE{z+D`1pqA4Zm_LYCaAsaKYpxrn`GT| z;fF>;`YIX}cO@7A%K2=Z%ZN5yD0oNFVS*$u4NTDXp;9KO?~FY^>f|70GLM?A#Rr5O z&i@|h!ZE~VsyEzaTax0eVtfPT21i?tb4O(~p0JBIxpNV-diBkLS@FZ_Lih56Kf^Cl zs`?tI33C@NN#L-xE6JbcT4o;Hl{VsyPK-NMG|H%0NF1y^mO=GKjyVX?qme@K7M2iv zy7-`)cE-Y^@5g2=UUTPCpMSU##NLo_@Mb=y8J7S(?%xwLabXaFnxWfJwH*eu%x&f( zyH6%2moJ#M3C~XXOUX4Q8bgjF)t52+HNN>2V4>Z^Zb#OBL}X(-xZY|@%6s%;5>?v1 zEt%YkmPp-oA*3|RVj*r1Sp`>S)UUC{p)ft`tb6C4QcNOEwbPMXw-Dw`(5NF`wqUt> zA!Wldcn%~IO7*L5+q74;x6?X<5SGS% zeEIg>$ol87m=b6(bZQK|dsftl(K3pqi^x!U@qU(m9ChE7$h&(USv>J9l zvv6g<&C&#a3wp@?D1o2ded-^hHghj`_H z=PN!P&XQN-S`42!6Pj=8?)(f}GnEO7*OEe851d~EyC&&)mJ{S5Mp34ZKg#)-pJ4HN zO>2D%x@5CNd1#`16aO;*inPS1wdGlCh-|?E!g8Va=FNe)T57HQoT%DjU}4!V#0OPh zhZ4qmY@wdPIEqdOo$pYeCM&$)RXR1G|4SqrrlUCf+3YdL1%^26L)k6>M-T2I&eRMr zPSFf0(Gu6I?8|a+5i7qKBGDD}%qj4W5{L@(ju2W__S1#RW?X}h@}TYZ%hxH z&27?iH?i*l(_D`#<>!8%a`fi)@)B*XAL8uU$>MSkC{rbo=HJBLpOqT$&-?fu9K{6P zUBl08Kc<)Y!{U!L79vDRRWu1oY(AVlDE-KQ7pk=Rb#z3ZxD~o%LK1RX#B;L7*Bbf| zHYzoPTIr(7AQZ{=15j=v%@|A5{h=XA*dNwF_|?7IuwkXzXMeM}vQyXSQ6h_C8l$4v z0dbCTNSmq*Fao7g&!8SEmE@=}aB%PyvDCuj;q&I3{117{`Uc+S zWpS*9-ZBaby@gXz;|`pyolPB`G#*bjxKSFt+X_1@(=? z4Zr<0YatdT9rkl_qSa$N8hp)o*3;{5l69FWpKF!mz}X46W2(Nl^(2b*G?qp5U&Qw- zhxuGhFgVWAhV4X)!YF#%&j49t7=xHYb;^jVfQ(_ks{1#q?GJV|2_rG5CgGkQDu~vS zWYUpph1BWEjtksY=rNI?HFjhNl(Ut_XSc0FTJce9TH|UX}EpAha+WU=k zzJz>|Hu@NLyt&r!<w zk;C9-E!b3-eb_dl-|p(C;C-uCXSap0Ep*v!LyBW4l>=3;pHWGIMtYv4gv=-kNJZRB zZIeJZw5QtF_Q&qOWBp@i9(kADz^eP|N3p1BOuRKio?fEGPO&58qR-fxO(H zPFv?(>FpUHUbc)-r9&!qK+RHc{|Ck)?6R4;T6d3tlz!F0ojad)@3vQgAW4}0TMMfk#=zMc`>_EXY4 z(U@e~=%7I=>>pdGlm1b-b;(KL!vn`ul^4f!%mcKB_UFwyS6#x1K1ddBblq4Mw!9d2 zCZIfVRppJP0>O~?t%p$eMPJxKMtv~08O#pwd~!G!NqQPhie%w1s89pW?jd}nT`4rq zem$ppd%`)<`qbr?8@Y4G)4k)V$SVcNb|^p8#S}k75QwCm%UNa||B7NoSeSyN zRXDEomsu(4$QY6(N^)v@qxQeQC*{<_72tTLCHb^vayue*Ezn=|Vy^7zJt<_eI;l+3 z1}t1Y*qYkWVO{gCsDEjtYK(rMf|4@bQL9Q=ha7p3ID+TDHE*<#s&4csBP~Ec^UKiJ z2V16s_Q)Tq&JbSsY17CZq=0KyUy}lUW$$q%Y_s&G!Dl@CEz8GmCcdrK^Y5am;xw%x zEeOJ!@`5S>&#JFNCgsoqhUMzyZExARPT4LZ#cG@}Ha~LIWnVj&Cv^G_?f66pvW}}v zvcUtt;Yg2kb2Z}&F;PdU+kTbFjAfGweW#}2hW9U5gk1Co4oDo`C%phF3NmO-G|f-@ z;tubBn~l+ZnI#SLd{EIRGMbjIv@la)CG*_Oy{pC0*vmP^VzmoGKtrsQK$ zp#?X;-}_opfs3(=4P=#cP`TIs&F2be;5p(NiQW_EL|O^{g_e4DBDN>#y4}yNS#R>j zYu_40@kFIAFPsT>hZD!sV6l$xl2cXE(X|o;9}C6 zT1;iuTc%#B`G#h?Ja324?}@Gi#KmWsY+bmhV7H0f%z;FO6xmzYRc22rdtES1K0dxW z>~cSK*xhgsdPcQ&2t8Q+s|Rc6cMS2! zQ?M^g7B0J8O3%;;rOLp;Gd5B8DQ9LBxhNy0)g6VbhM#dph1?D61in{^Oc2pWFG!_C zf8t(f>e#V(a(uPj5(Xf4j%=cyV)oTI5wJYA^y)^2NS~sLrpeEi5{}phcY-913mcvI zBf)GcyLMD-C9;y@WS15&Yq3XZYDbx(k^l$3>_l&IfuP!(UPTy*9SKe*8 z`je~ptl-<}%RT(0;cyMfX~=O@y+|FXYuo#86=$*jYxt{~ z(H-{hheP&>@-1C-|CH*0soRFqOLnp6E|!=*{Z*b&hx_V}GqG7liSO)3n338_23Jwj zr+VvprgxN<66tmFsqWl{-GTR%H6i>3*-P71j5l7Pj{aUDE`fr{9M2?<>L~L(Z*SMX zqjw^K3;E~)t~j^B4n|S5iPnHX8gU0(r|1?_5y{md(*yk(_NVq3@^0?%)em1dW{qU7 zHCv4f7WuGi3rSKr!k7ixMT z&7MsZ38&k%Z2J0-?hhWD7zLwsBmxEy{gDeU#4-)eTxjVNKta z6}rtg3BOFSF+cys(8pkb?fvGyx($xV+^7#3z!JmOg#kZn8fdcgO=DD_7%D5k9C0(Z zx7Y9QI@2cgUM#%#aDfhg#&a7ygk&&@BPi~eZY0zxVoRY@NjOv!Y!x#v`sf#G^_3>n zGSCUPc4Lb!v<<{8uzjJue@U0x#4vhcrM#}|rqh$$J&d$=hlvbAZ{RiP#*YCe2t1rY zy@KpD&TWH6Aud7pyv~FKN5x(lKLW6prWQ1)14fCaM?pK0CUv~hLOW*l1fl0Dc9%ks zSL-m+?TT(&!r$!c>S&Bs{4p#X)B9eof8uHi;)P6*qEtr{Csz+CSz+EorW9?n=Xuu<@Kr;L+b^9U4Lcj7Mq@=@K4!LR z?katGx-hhSUEn%Ylcwl21$m0AFkSH=RWzyzq+(a5x#J11X%N*yI6fjFmmuhLO3vt{ z?AZif^)gM>I>+yL_YSBB?5XtxKWUQ2H`-9;kd)g-in>lpenT)4)%aF;Hxr)j&Xzh? zXd!a%&ErUyLt(<#G(6|GDhQ@4Yg5oT7`PB2MZg}UxWGt*;n%iPowncc4V9Y;@MuWe z@RXt&+QMp|<)34;LpW9$yggHw&`}O-H6-JK4nxFS+}p{&KrC!3)*&3Lb83Vzib@xFRt zA$B6WC#)+=V|<0)i2Eq#Gb}?MYpmSfEXp4gzc|r{_5V8V_vq!@0Yr+LY`{u?@R!8+ z3l`b$)AO$Ol+O5fEw_(ZF+tbv&}7Jgk3D|XkRahI=)-N^B!+CL*3OlF?43j}a7J8^ zlJWkBww&+Vqd1-`%g$mb(m6$#Y*f7s?r!|OUzaZ3k{Cvq-SLvJ(sfSKK`DsNW8Ty) z%_sFb*F{onsK+TT(-@-h3^vv}7#ih=$L>PL$Mw{DW6B&s~+0)YG0g{izzt+)RZY-XyoaA zadX3<(ki9dZ*Dm-1ge59__FHW!7u#MG27PLAwktP2wHCsL1RkCQ^IJzR4A(6y1yOD zwNxGuT$h%2E7qH{uijfM#gmgv{BCpW+T*Hqkw@LW4t?aLZ7q_`H!MDOn}@tpj|DF> z#3<1O>=DV7XI|%D#A5g!_$+#Pe>6o*C0C7(Nf02G9Q85`<~)S^e# zDZ#a;h8rOVe^RJ~4{=rE6Swdqiy?#7>N9G*(>EKBd&}fYKfHVShO%A}qYYXz8O}Ha z=WX1JNNL1?Dd(Dt7kU>fS|;#gJs>NXvPAN!Z&&*j=pU~pMaqbrus+uN1$Os_LtVGB zF)GqPQc=o7FS=$Bmo|#{Iwp)P4)orz=_Q%s!?#7q!$dFYWf(#~%CJx4^aM+ytR}b- z|8l|!+st47)&w`2uB}XuzQ2DY11xb=>D@r2YU>0;2|0365pYi==oPYByxfn5^i$-E z%^n{pj+O(&lT|4<{KmJn&%SuSPwRRTn^LCi1$9B}J?urwH1z~RlKx@MkBW(ga@2_; zu2%M&WtOBg)rou=Oz#p5>kz7pZ{K%WLgCz#TO%#0IigsUCh~O6+>TU<;o{&xE>W_! za0hW_S8vTdU_5mL`%_``o0hPaTtz=3Xc{brxK56s!mC?`Q~7Cu&lIFaIrTNl9Yt%* znx0=S(pJ7~mptLVkUzy>L$(9+8|o|@stbt(7GgHiw&>j#Yd;#bAf^4*TSA@FZpm^<7qJzOLMbj5=y#nS2D|VJfH$2yV0UV~m6&=JYY6z;{ zjEK79m5|QUyVb+lRY-AfN`06TsHSRhpti(6#o0<^_Juc1^7;I`@d7-`d2N(qx`*gG zJxK)bgJ+oz37-dhJ5{&WePM!%KC6oA!pGxtkMlZ|A)+99hd2?7P$w0C$@~fYsE3VR zkeC((+Og~?mC}&E-5cN?tclGGDbPx(upk3&=`fZFI-^dhWo!c|@Gg@kC8vb* zt`fcxRw8fEpRoaeAr=5cu8Ec+suqAIi<$iuH5aHimLSEZjfxzLIPQm~vEXBrSR-Rc zTe4(VTwq{$ci?*AoekDgx-asRcTIjd{x}ZAjQ3y1yexTmDvR5-oyS1Ui8MhR8M)+Yl89=_-8ZR=c;BTqH#?_CcpQKw2iJqeB}{K zn|6j86ZAqGU^uaX467BhChNCSoY(lAQ}jAh1HYKGZ3dq&q`Xx*V;+|!qwJwJcx%x! z#U1Orr}~eqv@ebwH=9I(G3r$@dy=x5*g0XG$QINssv#^!`cwO1X)G_j66dMN$!NlH zw&dl5iKaYDX&Jqn>i0Rk^E%lq=-(|))I7bb$0}!KY7J(C3D~M5G;Zfg-A~!tsZAVw zTRG;zDS7Cgx3bw&t5MVt{itn4<^a@FaWu9OTCCWpl%PRlMuEU)4noI+P@mU~)8bN{;^w z(fF~W=vx=|$7J-zF5@yv_Yvk$Z`*=z&qRS@42=3PM8`igF!U(Hm9VPNs~|nuXs@|}h0M`onDJu7X7Uy7M+e#GkaE;WuW@fi8+(%C&-!IpI>q97W}sd6Bw>tjx4wP&0Ch-}616+;8r zFUv8qBoss38Cc+5n4r#RCTOA^N>KIMD@W13W9Tc`N+6eQ2Z*=H1!NR|04Ch+Rn)ge zIkyuQLxTYS8^JA&FhLuqP&*}PN_-ll5W@s118YEt^q+5RTrLe^aMGtTaclq+__rpq z{-OC}WGI~#H4Ajd?%6#ekZBKx!lki5VaY!<@E=-_s4-=NE>G@iLV;9lwH|J&XX5aG zu3Z-6q!%xcrdMN_0=FiC|M%96K83~f!0pbreTA0h`S?zzOvGW`Vbh*NRbxZ%9#~QY ze}ziT{-zjKY!G@fbvo(W-o3KM^iT8OcVEvZ2B}o%F`A(V%W#ob zM-j?3q^QVwHCx>%wTPAbKm77y1(dIN-diX5bavk2JDwPvx`~%w^fGPqs($Z}+bWt* zrn%9dX>l?71**7O(Trmh))pHvfy1fSQ(GcD;P_xpp5sovMD&w&aLSWb{x5}$PAC_` zm<%x@C+CnhzTu-{OVxGi-WAp2#&SRjCK7(r`$Vr6fVmVS~H|XssSak;2_+;g^h%O{o~c@L%>l)#hME)-UlN-gD_5dai2sUDa}Q+$PC3& z6=~;5lT99`Ey96~xP6NZg`xMV_=F0N_xR>KSd%WN&zRgj1HJ^+FFY};A2XJ5c8p7O zdPvw0ojHofj^71XIBs7JFI5A64ai|=9U=9E`JJvzTv)^(d&7-!bM{S1>WHukR6S^- z{76&QuEpU{ALA4pX)NW?zv3US?0PEM$msjA2lj*)PVq6HPrQ1;6P0lz#5#v>b>b7} zR|IcX&x|@kch5f7eQ4X07?(dP#s|K% zJiYd072zSDSByH+kzrf@X=wINlV?m&HM@kzcw9iyub4DhyJ2!IgIc{;yrD^la@8%% z28;QV(zbOdb|i>(0<|6mMnI>VxG{So3D9{F%C%G`ZGI{#PTuVw5E zZgK!ON!KunSpUF32R{;-Sb>+b83=Pl2eqTPgX??6tpbCotz|&z)uXnp?Ga` zQe>wlg>O<~4JcMqqUUV-SG7i*REnba8#z7leKW0d<3*InxsG{-qc}WE(>$(}!3z~MWF5~N1h@LyAAmpGXp?xOwX3;2^fALVbBl)g zm9%>zc!>!r9G;z==msHy=v}Yrf(Afd9v=P%Atf8T&@rdWm>~A8-0j#=kI`8XG~n2X zGj}~IXZW*7;oC?OWMwDYev!dK(tC;k-~DLfSyWg9%^F>NKGqrheetlg3v}W{>hi}d z4_u+w?(&kz8r_dp`d29NmklTH7*J$yR&&4c`vrk3er4?r4nXY#d1v`IE%pfrfB#hV z>ZEc0!u+~@E8;(`l!c{h$sjDtC?PpPXNe)LBKM+AcfX1DY%L%xD0!|Vs=@l*QOU8` za%lkcy=7UZ2mddZy;|8y7jgpF_5HrOUceEW(1DstR2Ay2KRGlMDAe5lj>L0h)*ldT<-=~=-qPQsmsLcuU?Je-J@T!x* zj)L@D3bGcA=hzPO&@!N1ng$@xk07Jb0S#O3g(yaFFlKsha~J)8LXU0k>Y9bv6x9j< zgRV2Yv0F2$TDH)O#BRb07Iz^$4Dk_!TA$soNuOPw7H}t#5u85x9Q#hgfMA5>cPnvl z(7&Ik{f)sV7LG?Bg#$dB?WdHKL?MaiBxnTN`|W)G&GnRPi1sU{l@uO=7v8jSX97$@ zB$Jl$Nmo7aAxU0>hfaNQ`X=#+OS!g#aR^xAq{MTODwf{94IRtp3%kHUF!w^M?}g#H zvEdDaI63;ThiH02YY&bz1I61h!n6P*Z-_KyoI+E)F+`x_u0Xh%plNLY4X`Lv=Bike3*Goihk% zo1@lkjr?2wK9fI;apxLpE&yh0PS4V?dXIOr4o-=}5A8{&p2E}e4bydX+pGVfQ)RaW zrd((*Cb>{QwG`?5ZERpFcFos+@k<^M4pyy$P^9|INGTHU)Z5xWG3qjY**9~hZgtod!6I92JQ&Y zftDi6Jc)hm8Z&~sb(L$lyD-Y>omNFWxGB{o8X@BK5iVl%r0FyK!)lP<))o4)53cs* zjJi_%*Qt_WBCZKFVl=(e0*<+;7KgB!j=$7&aXzXrzUb}Fl1t>E(VE;zukn=eFrk;P zzO~5qzPc)H#bTukV$BIr#|jiHf76W+Ho}()Vy6Y_YvgY+RufYh_M>dVSGV$rM~95I8SP~! z<6PH^_#&1JI1-JHnp%)dx9^gnL^To~zqd|psN*F~M$Ng)qIfAumcpTV*GuEwzV7d| zdi(hVSM}>-Cq4pn>z@gk5{5Y99NDW06QRXETm${!R<#pxeq7S8)0L#z;41K7b(p99 z892HBQc%WguH{oxZ;xp8C}PcnOpY0vJ8`(CLM-RTygdU4>33Y5ujl_5tsHEw6xHxefpc;4P zK6L9u5?qo!^+8j-kNq0%x)I#zo{o-6oKF zR*XoUZmI7X17xwhDp9a@X5{WCH5={wZOK5+KqBdnO*3i^8iV$Mf<$pi^Rqw|;Mycm zOlTBA(MmSZPR*~qr5OC1sL`wbqm~eI%=_K#ZJfN6n;CKiwY?aa;`GC5u#}QRHe7DS zr-527sdw|R%s}QhXpd(fWY#5``vkglHXN4uw*U)(4d4m%KdB%5=NrvZ;T;S;IEIXT z#=uNrc6sjN8gQz77EZ73M86v`J95m~@$q|qi6N~wWU0ogp|9L;T(Aoj!LPb9CmUV4 zHqA}ttNq^GP@NH4nYyz#+=(&wFuS>fIYNWxx)t2@E_y(MskGmU4N`Qxb$vCbUp{+2 zL|RTj=)`?aGqxsE@ED~9#@$-zRDUbB0?xi)S>4+6Omb*W+fg81>?gU$Ri&6N!)b&+|4 zM5!ixayZ`$paASvc4y&h&(ZF~K+OUqgFdRNp%nrOviKf#i) z^4Rsdr|Ht9gp8>U3ejKs*o5odTZ7l~PMcL7&l4}6`S=O{wFr*BLDs~Nx!LTbK3xct zO#Bt_X7J#n_r+4IQt04Zi-_%s7U-=C&9b>#?9lMgM7H(pJhGiu(N7)CAr1hja^c)x z0XKsrOMzn7_Texz0EWI+q{cEql|2^nRG8xLD;X%w%bEYZztn;i$Yf$g-6331CsPsk zbjW~2|5hv))W2}-LBjS*cfnWf0)@+tabdEu3?a29%%(%#In<;rzz@nk037}^U{}&B z+7+N!IvChKwXlqr#pgbI$H^&aEc>;a*do6u95Aj~#Mo%ci;NV9s1JJfJry=IH(a$+ z{9-o}+6wem<7r9~BX+pOi@L1M1kHIsNNsfzOi<<)IgPOgp#EJuhdv!&CdmCB6hKFh zfoooZgn3))*{c#x-a$X>tO{wz{38ycw&SDbdp%l%2oY7rA4jjw(p zToMVjunOz88T`AN#pAI@kFt_}3NMrruZqGxAC+veQVT3j73C2B^ImlAlDEQT&vp5UmA9Cr9zT4msQKH4eH=%~r#U#j;nJy!qT zlaW%!BW+xClYK#nCGP+M4110CG+n$|Oo6lpN|Y?6q-JpPa1FUFquNurmM2fg2@Y)yhBQGr?0|Vayp2}WIK3F}<4)?w>*+Ha9$$AB|E5LM|f zIK>-vDGP3tNyfEWM@G#DU^9dVjCFWY>~{)F@h0Hx&)_!gY`$%6zm+9fZo%@_kDHQz zY@#v<7$N&K!hxR>@A3qNR#aJUm-$14ptJ8MP}LB|?XhDu zDO*%a7{zoulH^qnRO{b#4MsPCgNNwkeF>OFN$qrAqFv>hXJNG@g!e}iiZ1+Nu<@WNI>w8NvG&02)D9LzC$swR4 zn4m9uO>0LHvZP2fsb^-p;_h^`@NkpGZFgHIA}QtjTkTnPWT%*Xw$SybN1AWY!tqFc z#`whPI(j)ni3TBW$4QasG6yZWuj$vEH718}Z3-BMt*_BVqB#pH=fKS4g;q zI=TK*QK=?t+t%1kG$(A-e!R^??#xH~)`wr3+T#Q>5HIg2L!Sjo3JAv}jkoE|pH75@ z7=0I)m|Bug^x2gdMfu>~Y)bO4qnm#hI<-JJn4pJ~Vu&+kBv_PxDQU8y4Jo-8Xj*7| zZ#eVP7tbMIB*9E*SM+I&AmkfL2yQnIgI&04wq;)?le@as z)u*;@7B{-9>y5hPm9b~0gFsXLkYnkRfYzZjor=LSb`M$zP%S!IdeJ(XquJ#n<=#5` zlf=~^rHuza@jGe?LZ3#$A<7r{Pi@Kv5_V#>_9MIZ_6HA@WzjBE3TMuAeTWr(E9>%M zV)C}v8|xEaoj#Yv%JqMB#GW!l^E__2_dv{`Ji>6{5UtplKH9Y%jbkjbVR;C$l zKxTwv;a{wKANm)b-mver78uxE01BGdOm4j_J^kQiN}PUvvk^<>8F6)$LRdPKWT+U~ zL5gA=4y8Ij_yT;l!Ks0tl#6R-gPs`WrIf_Qqz-RY<*Ty##B!BgNBfVJR{VRSHeLbB zt4Y*BfF7^q5!lJDhW2S9wgBaO8le;!94&i>utUW|8L9GTw75D5UXP!|Q$dAo-BESM}Gx`_oTm&f`c{s9l5B=Cz$t6#cxd_1a22Rd^Ap zLMly%l8FRdE8?_S)1<-8j1T%y^KHV9~@ z1cGK{%)dizR&D2iMJqb+1dYtxi^o8`fuCD^(_e@gtAR# zjZoIC)AB_~B1_q(k}Q*bXUwM*vWHN_lr;(2vyNQ|*+Y!6?`AAxn5F04b>GkPyRPfL zU%%(~`n{e%p1;bZ`E2jc`99C%JdWeU2>{-}hkR%hWWQ3Iotd-OoQeA5*EA|4NBx8Z zF;-4FN$Y;G4?g3)CmKrq_A=5CRQ|mG*J1&GMx*i-vhBa;{kWrh|NG+l{p-u?` zmKNdy-_f^VraVHA0gs56DAo`DDQzP)Iqfl@S3Mj{4eajgj9`_14#x@+rFj)p!^;}bXKQdX2BC5umy{v-eTKL8B)L%`mx8Az$I z(;1M!XCD`>K9e%gSBBk9>#Pa2k5;>j@ws6;t_Ar=V@_u+|$KBIY_MJ zG6J^cz&DXNu-$6Q95P`$2x@`zGslWt;@t{29(M*zTRnP}8u1CL91W@1 zjKG!3wHC9G?F`g9UTT=(>#u8nsZ!ybXcw=pN;nxHtaHfDWV+<^lf$7Bk=sUOaqOBp z9{4Nek(|j6ZiOfnLSGnN3{X9N$7MeRxv|x4l3qMJSDnh7)jOR2y6oc5&39jWP`!MU z1#0OzItG0SW`ZeMH6~j@nsNE9l%ZLpI_I{1uaBa*oHW}wv+6B}wYL-jsyZ=cJ%Hk| z@(QI+s4SU@3#Y@&Mo_1ZS4wtgIU_ThJWh`E$0eT^o<&}|t&(IjHh!rc-KNDs)}`D> zf5VmK#VWT_>!HY^Yo8&aNnOx$f#ICxMuVQS!aE@>1xHF(AZ6(lib5S+pZoKWC476v!XSd_%Rtyu0 zn>aw3@r@&38`Ti4h6VxEM4_T^7v3C&%dOE4rX1BxEy|uP`HEo0`F+dik5O8da*{5} zpe6bZTdI$LZ$)pX7tWu@-tDP!7@e%GN=zQDq2kHV42*?70F|UrPwKlx`ql`4AS!4R znIb7z91lWc3v&@? zlBYI$eKMGT>^$-jyBqWzk%%P(K-4UC{3tESii4(rR9Yi^w)XnPA4GA64lHFqzs^_H zMZ&+)kxKwDnbPu>%*+n!wMu3cbX}WdINjrw|?twVU`(%Z5b3bkVK}Mj(xYl8^ z0fnC|(9Hd71;?XdIBUcu%@a8u%)hEXc!O=PFG+A9gK6@YLhZ*kGx7(@0~EZxVW2#n zw*yaAV|&@ElFAffI6wI=K;i*^L{zWnyMzwnx~;8S$E&6pv5*$~H{buB4eC;~K3^cc ztk#>BzG?q0XJpj5jj+MEuwyP}krV#BzP|RI=)lScejf>#ek9+r--j)WFbxhsW~aG$ z2TPa?7iHfs;7Kq0zGigguaJOZ{XzJBxtP?RI?oA|G;yxvyL+}{Su}>SP+!)HCEPw% zcNM?nU8pjHYmJebe0_z~Pc|9U{&Ke@TIFZ4^_TUk?sL_&BmURhNzLj|jGBEzcC|En zp-ZrXK(P2DN%-{N7K?B5BCQ|jC+3IaYa(QevOlTde&y+@2jq?{;_OizuO1d&(1G+* zl-BA)b?U8GZpF!EzpoE`7Z(ZIgekz;j0bO3kGxHGnK@QIam;dN!7S#{7N^eLFD5q{ z=ZJ0oiUiHiJ%W+_ou{R`S{QGvAvX}=Kd2_OH|tO6XULFs#fYNeQIbk^qWFiNbVbJlAQ-7w{B3Nf+% zaQRHZ;R#i;-Rx`sa1{*u;v4(Wwpxm;ZIEHR#z3BAkJq)#IIKYJEfu}paV#VH6)MM;roHZ%!FDu>DgYHDrX-r1H0M z>m`(jNNg$8iVNX)$#|Zodo?&pl&iWWJN2S_~)l zQBNkeYcZ1RDYyL%QLOlSTL`rBo6WT@eo3TwB9%s6HtyHFYdK5N$hi5kvh}aS1zf6E zzb#{@Kf+4$VzEx}VyaLVu_8V25BKtNcxU zY;q$H2pN44FV;RTn$9>1f8rL6)`-LRRE84O@f;+NFa18!wQ1?HL2Sx7iCeoa%QBao@}Mj5{Qj-oj?S$+X7by{L5+ic(4us2R@ zRa&#Q`kE+LEB80Z9yb=$fvo~taHg(R1cWTv=IAm;vw6G}Ji}7<4L|a-u zJAXfUnosK*W1M$s<>Y{>R)%asp1C{Xv6n$@XjT`-p{J*?D8cgH>f!@u)xj6I@fQMX zIetNY)^#;cHg@a#-aylNP#Kc?hyMMrW11t9|Cg4`5YTSQvE5y<98P~>jy6pR+rY~}Jr8q}LsS@*6wsfdmyzA$~1;$M4E(6*7f z*{nb8q!xE~EN?OR&Q?+qMP2X7APG0-dZdE(7A@Gpf5D>zAERVu`oNU+#utVnD>}JHO@RvHxfWLqfvM>?1M`wf53B7`jZ%7OQ$&=`qsSvV<2Q~(=QlOZ zeB}TUk@_a3ZIg0x7eX1n^d-6( zf$5pzst-6oDm4He&-V`f*H4(j{a<>+q(%4@rXMU6`Y2s93vhHGseL(81T7AImJH62 z52UE$MtBYMY2wiWfT>1<`oJjEr$@&|M#+GqoR-TPWtAOvKZUdNmB|f~nn$&f5?*_2 z=6uguH7GLN?!2?08;aId1&RkH?0vk&>eua-bVHBMiLQ0^#gzM|v-PcyQq-*l{Kac3 z5L|DBuYP~DOne^JB%Sdha=#b=mLa9+eqj+Zl33f8!AfSD4z@BPADW@vxlR&61@dedT3@7nQ3u>Z+SBucDzA^YEJu5j(CaVD#sl6zt-1OW4|6A zPK9SX4>)gkD&b!k`T2PMxDwfX`H0hT=V@lq$FCB2(8{#19e5tJndZ6oI4G9@;zWnF}KmuxLQS+g{-9IRL~M8Qmluh$cBvwn?zNBp;7CmjQY zF|+mTqqZgG!#<~EToX?JxOhCeB}x5te7ouNXpH}JIt#qWzlxMbjwg&ZD$@_w5iMFY zuQiXju^=s@D{J;%7D|Lo1yl%#9Y$V1>Gj2og>H9cB15J-Bc%!YV|TWAlYDiWgVnzP zZ}e2_%rB}YVIC9qxdP(!M~@sF7zFt=c7Kb$BHK&3+9><8Scp~!pH8(^q*WE~*27H+ zaV=|FYy;!W{HRi|y5jThD2VzR^TmQXn?n^&!Kt_4&Yic&X4X2;MUWR4$H(Jjg?85%rdZ(=iBDG&y($NN&ow` zgXFgqeiX8vRsnZ_BF4-A09R#b9r<)}Z*ilcH_NN@bfSm4 z9i<|M5p8w{DNgrZv4QJH3MEZttQxLbnj1DDi}7l{(Bm8km>8kn&l7t3osuJ~<_`B3#M9MzRz_dtL!)*53$ zN+u%-i+D`)=5ca}Q52kgo;dP6AE_mo&@O#nt8dHpoRzvZKl9d2t)S}hzj6)aL_iQ! zqbpmXdoYz2&mTX>JFg(hp_e}CcGr?L4ADQFN>+G{EuYj#7#`=p#QU3JvLH;R3fLhv z!B|l`WNxqt>d_LYs>09lhF)zM++?0lO9zEX4Yi8Ci8*y67t53PxGcjSUwGN{DB>t9 zTjNu`@V)tCrmLr_=yDIt&G<6kJ*bSba{Rd(RPqi#U}8Yk8h>}6?*6(yaMg~R^o>^Q zgPAa*AM2q!AUn+Pg}9(tfwS10r$5xjuUWA;v4(2yH*oWFQIJZGG~G{e|E0Y5O=*`8 zR6}kOU7rSS@i<##{8YD{{gJm}<8rbCD)DnkGijo)?~Z8VRL^px zQr$qzxrh3joI^Q7A4&~=M`sG38C}=T*YyglvdsBzsS>{aAW}NW%0tbvuB_a>^N{>dXl9*2<@}&9%S)}#s)P4xSi@HSt}zWTZ&(-$ES2% zMeo`9p>25>??!XKx`A7F`kudSwO^lZFwtr1acfkcX6K$>xUt%`;)&b0UbduvF{xOa ztQf{zgPOG9NSiyP0a_~PX!9edm`3PA`Z|%lh4h}hPAJ-L&R`z&0dNE7^Y#qx)K!ZNb%@t#>-2R!kyB4AIW=_7 zAKU(`wjg*4dzIc-!x5`!6n?RjC}7?Xj3ibRG#B32Xn2aJ*9d8=m{Ur6g%xa)xL&&-F6#-t^==n+tEUb#C0{i&Pq^D|dQoW+ z3%a>(kgyd>l~%n-0Ejmr=5-(rxr~?&4XhXpj}WsfW+r=^k6G%OpNNeA(){X>uW{dJ zbQ^+jD+2pybG&;N9-U$yx!Npzad>`M1D5%IAEo9t!d!tm&d?D2i}NDGE8a_k9z`7b zVEK3h12y(q%9EC2Ykv;I0m!KqR_4gZ%U>}$-=5rZ!$lQ6%su}1dtp&=6_n>!P2_Qx z732K1;dl9ZIn*-O^!FGmF`2;1y>gdIt{(*)jvHB*b15BDb*ali?W;K{I-7WG1tLnr zV}|2r8i|1@7qLT=YZbomm7F)7R(r_-)gzlNIuu9uAJPBbC zLRqxozCi1&L{^d$EeR$4(Gn$8HPY~xf5(;4inDwXN}O&1PTAv1rP=b3LzzaIs}uWN zj2&JunKXW>QLYn1fMLv5Mpt?LemY*m=q+Rg`g_Av7^HVBLA3V@T^= zb6fABcu=p=QXwa!5G*@YfUwtsmW85I0yEN`0-%}G7ueE!a-vht=NzAOobD8Kj5)PFkGvty=6rP5@DOW3W}Y=QFfk39)0g` z7Ii?q-ZuW*Gk9eM?gAULo3Uswo=_)wke}gMp~IaZQF@-*keBb=zsf&3oIXD@^|LI38SAO7)7CuXq?Z5aobLbEk2dC z>fux2HSfPd>?4K7`_V~ET9zS{+tL9Qnwi=ATH=dtuSI9KJe{PM#w}`UQa-R7IafFE zoI(r_l{YLQb1Pj(e5>A44Zu1KqneTTh%gfOsFvV3*{g-lLnc&>Pi`dTPxUPtXjop@ zrX@RN#=#t-JHPT=dz7Fkdd3>|gut|3&7MT@rvI?zA;yJJ#qjDzFB;ee>hz$Q0@ljf z+K~&{R{0S6Ca3%bY3><~&Je5PUbZm8Ae}jdNWm930b9`~4?@OvJJ=RwhUW{XFtP;@ zZ`#MZ*2_H)A6dma4=AqIejl65e7A;cC+#A^tGF1e;X!ew|HzOg){te5!f*mq#s|}3 zHVPIsYUR>LR4(aBUm<0Raj_;hNBqj|?_Q`OW~{ATP#(?ih6pNb!A~mCHbT{3=sWjU zH#-gWCx;PGH@=n{bXp4}2qB;STkwdr7J%fDUJ~V=dEd}tA}?$LCp;@>t_Cb%cHWM+ zD;siP)Oh<${+j(W<#JnBp80Vv(rzp7L1ntAr!G%V;gG$J(De?fi%my_kDb}sU_%El zK?Fl=v=$s30P=Su8I!|RLR{+(<7?~HjcSPLVfpF^q$N-2oCPmI=DF1~w!w}gx7AON zopAM$bx+z`D3SwyY2RzK3)6?^F1b`xRaYY&g~AV6YU{CZxX0||-Mcf7ku6z3)&Ekp zLl*5VOd@=ccT>>=#D)GbFDqAMJqC#?bf}Fd&r?|aDXj>CwiOtYP-asl^mU^tbUBxG#_3N72guP*Y%APFrYi(x-dNmeB_>VISNslxq1Iy0|Vi zUT)`;>N<7tjJXk&E0OdUfD5MI!5B4crdj(=T9a$_F>O_9nDEV_c_%rMf$@<6R%Ado z$L_0aVT{!lv&M&PSmZZzIuf@Uyr7TV|9jXT&%104Sf6bHh-8Ovy7c7PFS{mmc* z*F6xHASTlF*%m1!fC9y8UH^ZS+7M8+erLAcpb^fBqRG0I{X*D?rRi%?9lx#VXY_2% zYL`aXJ@#|ocdsqc$x^#D zO3*({IWc>$s1cv%9HiPIFU+?G~)k3(if`yBsmBC z(WMvQaWG-njP=0XpWjII(^XH(1Ph#xb4+PAAMOv@)tEl~|ITALJP(UO@Tc8PFqAV$ zbSb4KbcJt1rSN4(wdEcD0O@dNhkS9I2WJhz!HLM>&&+##I)apZ_515aOw8YKmT-tV+1%^V77Eo zN&x*LV|O?a|0p3KySOM@aoR`vDHKFz&G(1XdHGr>tT+zmK&HsY88`#rK=Z1{;>)x8I5bWL?2Mw1JRYskeeWp#Ile z&8c;Hgmz!X5^lm{)hyPSDd(x@waIs6>j@m~mU1sEf^vzn?<)gi8s2SHc6-rM5#L`K z^bcPH(#hEgxEq2jVYs*MM7e?zA}&OIZo*GsS^IoGkb8I{=r}^#FRP;Rwe|ff!;@zm zPTjMA@cb#dG_x`ZOgYJ#?CGOVIcZbhnHWA9L_Yp*XQ-nQWzWNr;8L9GQoeVq&pYlA zv7?Ru6~#&fUID@a)@z2i8aY-vor!?YN=)G|ECmJHt>EfD9EovsOO9mgL>YXVy z+W5PuXFkt8D)dMKlXy{-=|*9uyT_JCy51^Cq)lsZNcDI#uQ8mJ9$Zi7o#JnXss|2x z1#ANijQ$ok$mug2r)3j`IwIQ$U?1sUCFL(4$YgOTT)6nC?9D7o^jdFYiFjRK z2aa%)<0+O`i-!y~2}_fm-jlPM(cIP5H9vNG?sf+pjdgg~n~eXpZ_Lhf&RW#*)5}kv z81_C%shSni95=8+!3GO{*xd@OjHaTNAT10#9i{abAmh7XqhLJc@FP@sf4Pjaj6Cd6 zOO9yfUdzX1H}=fWpPz|ve`}=1Qo?QWA}O&A5G@y)&PH%xAHicSIi73yk0&Q_ye!|W zYWl3pgh9HZM&llRs*Xo*_(^?Pe0M07RD77ccb%MukFr6w)HI|OIq?*MD3Tui`7Iyoz+|Yg_A$8 z9`~Jox+P!yruT-3`{4%ohtl!kv#;>@t17C^Qp%Na$$PabC^8j3-{hEA_vwlvs zovcIf=j@{LhCKt<`Xbb_9i;r$yUgkv;rrY16?YvSjwQo~u!!nM zz^uo+czjreu~E-XV^8b3U8vEjz+3s2ggEuHMg^sLeiyQz_@cG`tu5$r!#yIF8p)qR z3&L&{LmZLmHw?{l@ro6Od0F+TNS~~!zV64=t|1ZL`gi-82P>(82;G(1V)ZH9E&m`B zT3y&PJ6bB14=QWugN>oSY8gp8j#b5`c|>l*Ciu5W8DGiByyEr>B7uMU7dS@Kw8%0!MN%IY2xAS76TJJAkV_ubPnX=7rFh}BJ0;cl>wUo;jM&e)7 zMl0#Mz$vrSizq)aGXm(*Ea`Q(HSTxp9lpBF)*`Fp_hPzZ5Y#}4u%Jj9NEJfDx`_=4 zvgj_JJP!+#`GxA5R@;L=s7l4E7+MhE8zcJ*dL4c<*#2hN z=q{U|Y+{aZy z!}5XIiAVU+J8t5z?z^S6VMp0jJ9jt9NN8f4{tT3YSON|-tj=1DVE(Ip$G+|fk0L(S zH^VJ`a#_Z06RF0ASe4wx)jIV?@@HEsUGC%U6-r|0yPer}2@M@!&V~|peIK|~0KX3e z*)8Qa!>f-VE?3d8@Ff@#`I~`l3)qkIpnn?Ms!;zv4x_OwLDSNp1*Ta{4)@0$@Xx>r z5`dBZ4_PG0BB~Z?hv_m9z5zkMmPO~skw8!IJ=9iARp|zVAhUl+D^Fa6cBDT5R(vsF zwa*C6LQ6k6dy)&79bKc{!CHRj>%3xK3xT!XqZKW*naT#GP76%~pfIxL^Hp zIr(x##nGf0w+vVP_gjQ(PdogMe_j0ftxl&q-%iA&@@UejY}%5xmyA$Csa3s-exD78 z0Gu!=Ul5X{cIu!adwkD9qHEW}b$zX3thP`I&JTfv1uF#Hlx~I_lBMA|xaTI$p*YT0VH5|z`Z$>QZf0qY66bzE;+J-NKP2snZiHU5VZ;!AmalKDO3aCoyv zQ?WJKp5j98Ci#;MKl*z$T2AHTC;Cq4Va>4>)#pXO9p&LZEU62>sJd7Y9Oe^-zdIHK*>pc(sFehI&s;JFWoo{#*S-Ev>u6yB3FaoT7A4b zFj3skL6#1up8UfeYGf1MoUVGdL(zmx`ZuYRH!`oR97#;~&#A{<*r<7>uCUhi=~B}# z9qBF14Ac(eLfMUo)DwC0H7QnkNiOV1d`FDuFtVpikIh&o_+~ye;PTXyNUyC;rmuin zKri=gRHsPEV_%H~y}86y<59dVjBNMXJ%`zVBk<>^!mi45Sqp`hp5&bK>Rb7m`OQBf z^+d;OX=)kLoPrHu7Cqh9LMqfetjqC>rwRozAYKhzI<5gev)59XGh*^vs?IYR3uSWmH#}?>m+;CBfd%pL?GwwA?iNGI>7|V zK^;G+*9KANXXDBW^CEX#^q21^Z<%st86Df7f_FA@9M4>80L~eXF?);?cN;4=7p`7?g@d? zF4bwj4rlU4P=JiR+&~(A^6Uc+>v~0+=FC0w3(~{^3o+*wT7Yt;NjsD@N%pKJ| zk!SzN{*~NK*A9uN(gY4t4Y@iC=&Y{cO<_kQGB>VCLfii5%Du+|u;cA% zxeKdzVqU|PKHTy&Z1xer#eJJv0ae=p_y0qte3Dxfo-Xvb-*2f>VVz~S1-CtLFRfUjSFdq46 zVXeD29+pT&S+A&AEMO&4$GbO~?F#UTN&L4mR|^Z>5^+252QnKob%sAW1wXMf>l{}g znP{1gSn&Uwz{^!&R}FOHpS8o0sa;|ILHy{PU2;t%NrSd5MBx0Ei%!2Uz^<84@rrsEFxcSWAuKwW;PN z4c(I^_x?W0D@pCVo9%p*=MYQQQ$}wrVF1YX;Y;uR!Jf;MjHTLug!}k1fkJ;_1lG-T z@&wSvXVFg&;`eCBGT6W-BU1&G^A_jti*Cw-RA(Z)T{Xnq8nb^1MpeYN#2rWqGymC% zihnck>He2bO#aI?NP_L%n;Z?EE;1OtFfw3-6u3j|oOTgKooaeMHshj6kJ6b{?D ztpVC;&$|(H*}8BN6?i)zE~K|&c|Nbzc}!EfyRodemLn&S%3FF0XrS-cCO{ zU7cvLS6)*xG7jdAltUYF^CrgH{&`=O{mo~XVyYP9^%FjuJ=J>Q$? zD+SlVXM2oru%zH$w7aYM0E}OL?QG!|vnL-RdVgE_=<>0tMh3m&>zSchH8{7F`+)^; z#GC)AEVW1n{2@P(gmO^>2hoxUN0=j#6zBS6%_l3^vx?VuQQ&z_Nq^Zy8virI=i!5bX_64r0ah#0$gR>C{KfrcfhbBm$E6^Pt|gI5bITF~L$L_Gw#Qyje>2Br`EX4q@E1WoILS_b<>@-qlJ z;Bpy0fUF&AlB1sm9LEn~;87d~AN)z$?ddq+)JJXuI3kP|2>-V)1my!5@|Ob23^0+7 zgM1dC>}}AG7QncB29Egu=?uWkEdx+Wu)xSne?+J+A7Ymt{HYp1r212P{OcLVtzkj* zqTo(p3E&)yfhi1#D4b$o#V~SE57Uo9t(Oox7h9oeh`<5h@PGYnT@nI7thCK&1R0#* zXz>IK2vsi=080K>IQ^C2_2PpNt;E)Nud*7A4(7SWE^VaH~KUq#&Jb%?VQ=io| z#nKNt-L9j$KNJh$+5=DS*w?4W)L1pK97strp#N%!Iz3Z#dOp5+pt=1Haw-Z@8(S;6 zDQC!+rsc+nmp%Wutj(-V|6H!di77cwgD_VTH*XhSTPP zm*ZcVGF*Gz9cc7#aLj7}1-56X0R!K9l_n!QpP5N^A%4P`f8f@)sj-CQ!A@@7(@fou z)7&;iBwOFP78iA`c0MAb^dtA~XAiF>%=^vb`XzE|3j6Nx=v@f5l&SyaR_pfGt*Rh+ z%^iMJ!vzn87kqNItjMSiE>?O+8n+m;j}!#Vkinj3@*+4X&7>PLfJY61((YixD@_W~Df zMAt4_Wd+>>q^19zGG(AR3YxA52ak)nPH#zu9S8tdVK-{rMa=FN{Oz^|$T-glI?#er z=Ft43@A~NASErPgh8GpUy8!LQKzAQc=q?_>zP;7j+DB(OW=0a@o`41kgJfp9po?PU zr<}L3VE=SHyqEUh%rWZKs$AQ~w z>yIM{x8xZfB%Q%jt?p+uwK(`6j_JVQ8vp?^j03mu?ckoZm^N-B~4T8;o zsbp6}|J#p$xMRTeLc1gkJ%w8efCed9@YDmzOzc;VcMKldC(ZN?pMtMH^S`#lC5NDZ z!#dxi)p#zSOVc*BmuzsXy%@OEDJwf)`>HNMBYM(6FwwYQ+sPzHKZB+-u`MQA4t!J1 z#TX$sMCF-DF#qEY80U4}9Pob67m!1X*EdPn)3gLSxC1}43ZP}d3Nj)Zvw6>roVP!y z?hY=cVEI4+Z$wrBJ5`$jQI}rtjia$FU~fuqtI*jF;6yXjZSX)0)dA#GT|6j7#k6wl z3xiNm1?{|i>yD*HIRWrwCl_1->fX-(%?_DNa>S0-lngeq^emg*t(0#=is@XWt&wZb z>ZFQu7 z3#;Ix#Mp&;UD8{S)w+>EgnT%X^_wBP)D&A*MNQj#2Ce;39_^&6kuL7eUq7_;qX;Jb@qV~ zx~G(ZJ`Fh3fsKhl5k|MLC^yz6RiOL9vj@+A?m9(;V+tb6=l!SO!@nJI2>SQ40+tc5 ze>w$@wP-jY}(hAgHITttsD#$M?=QXKPv=fcbH zhuSmP<<&WD_pQN|L;GS4U$F~AI0e66fACL+?NQG9`gsF5W=%TifT7ZjPg3kE+L&*=_84hb$xY)W;1fb3j zsPo|EfZqTwha_@9Iu6DoXm+FR-g$UW`dieyFXcHBzO%4S;9&5SdJQCCS*XM0JCrMT z!Q3ytRu{QHhMbUa5@TIu3c`+pd}=bh`M1u9YOn4_h8gt6<6F^9!<2N(c`3&GlB!VX3w>zoR8bC*-up0D^Bx5jw*ksf*L#Gi>iEp+|H(6VC5j&Qas5k;PRUmCvL+tVSK6A zJ~68sA9{RQP%6ae3Ul_j{+dcZuVk@0Ed}mee1txRVBv1Zaa2ghAX+?elgs^+Ms}Ks zbt8j{Tyav5^!T_$INn~J%?T8{W9UXhnDS9&8<4tt7c;+N;=n!P2m?kOanBDrVo}E8 zIe@K951)_99rWJc6?kg<=mldKV}JnrwBY%1`CAN1<5%T>T}zN%FO_`o%5<6{tUGHE z{1lA9_>TzvpMReIhdA_~ofGytz)JiV{y`oKl}FQJ1r^Rs^`^m={4woD`;sb{jSi5+ z$$0J#p&o&i_B?>T1|9r0DrY-s6pk% z7FJWuE3r{9Re3nXu=WgDiQr~I9-LVXmNTeIFuK%S`w_2;4)k7>c&e&&@PML@?o%^b zqfL|7p3KG?EWZx^G|kZf)zp)olM_4F{zCTxB2Gg*1y0scSHC=)csrh{6=Ve+&{=H0v7=c`})_Pl_I@@BTK;6Xhk;U0}kFEzA^7^_)PqbZDv8qJyoWk zo`FvT`hIqhF4d9SKI?u)a&{s=jDLN2FW}W~W1l(vm0(6AJ=Bq(5ffS-6bv`2iXG`4 zR@UWbKM9!xCIm>N61|>uDL!0kxu@hXhgk_yHMfx}HOtGsGuTu7qx#>99MUHuVj_{Q z*N_CNG7v=iO)Q=zLJfnvn!j+g-n z%xba6HG^&J;S(O^G|;#ZfDV?h-%XW}+Eodg78QT|@dHd5jS~VsxE`k=$)~|x^lL$o zI6%O@%EQ;T8AoR$bJ#nnL@BLmo~`(6X5c7?((qz(ZT8mtXvNYah_{jXEUK3Uhj2&E zIigSayV%^j>qMC2e7>iRlA{=*N7Ojhgg{s?EFnVeF;~cLc(*t$K8bLROEW<~-z^8) z<{kYY);64-z+ldos=z(}MW8F%Ar`LE)=eu&SoYGl#7cG=!H&vcJ}rc+==#fp(z{UQ?>zL{z=Dz6^7wWm825zLS8jQ0}#04*R{Fi2KS-!3FrE%@7UlLOxbNXiQ-#kKZ zTq*uG8;>|dH6rqbpbw!3aV5CcK4$U*6bD6S`CuD+- zO)BD5wP*ROOF*hrvIruTln-p8L5|dPg)ZsucXt57-&w^SvHKynD_NAK_PA=sr>vIc z>?@e;z09XA0xqIqZ}~PKv0H2F-irQ|Q<_#bCCjpGKsDEUvU%Cb7w{TV)9((t`3YH! zo21ChF4yAl$(7}CKQ_ZLgxjzX3@=*h9vRkx;I4xe`qvr{^4+Z%aB&;n$g8;Yxpr9n zOH5K%@U98-ZPh|3465X4fklryM9OJM%-V3Ynm4c{O^;@OdHr4S?QJfiVY$8NZDvXI zgI^X4BRNF5Mq!~eIp&is>o?f6VJ<`8-WVqTzSRmeS6l_Xzqm=1)vjPnRsn7i;46xFe>7YBQ!+=Llc#{1m0+UmfU~`r`d0#y6Rpr zqV4GeSQv_r=rs$3>AtHi2fJYfV}kt&Dn`hzdpkes-cIcxnQn3TFz?s7p@tsa(K*p9lwbe@eo(J zx$^abhmMeH=lQ){EJ4*YDkVEW6_YePT%>XRw#nR5?kKJH*1Lh-6!pk$1KpAXo_zW^ z`Vay05*3s~&bpJ>RE1+NOfeoBw7Tj>iqUM`+FXBmL88h}{7h#uSAI;Ab*PpC1x?3| z{^>{@LWvLqi>e5H%~(#VP2SSfSt5H`t~RNU(P@-fp|JHw=YsZ;1hwnJ%frbqxV-rM zPbIh$V0&&4FZF80z|$xg#mBo{qx|OSc^V3rj1|8XGS&~b zx?g+jo-2kq)Z&s*7W3-?wbyhwBO*_%*2>F8G;!W?tg2hU*95cmu?P3bS!Stb!UDG7 zJT96e3pk>hna@<{CeUY8Z8r_&Ep>V7=+_GC z(z2pYQf_rzxT3U^j^YN8Mke`s00oG?5xfoT@N)*p5!~Z?4`c)Zi-Accg^L1cRjaL3-V4+G)S#AB4{)%VR(t&fO;l0gI ze!*$ZT_cDv=wJOb%$%@paxXIk$TIK!Ia&f=PjU5^cXONz@ZLFLyP}<)VAywA_`Y%7 z(@$3#7gY=3|N5Uzr#NVp-Vh^7i$HpTJBB_*&UE4JUK?1kB)uNxOJQ43-|9X`EvS?1 zy9^73ZrFR9-ArK$lq$^Pdpwe{I%*?SxsUs>uO*)q=3tY*h0;Ix&7kN#XydnUl|K(4 zb}d+3wC&j;8Szn(l3}e5uaDP8Jv0hXKUze$&iZs(*k2E6IkGE)%>ZDnHk$#x9=C5X zjyMW;nj<&mBhA&ICRnJY;E5&6v`M6zPRs>`WhCh|q4~~P(+ghG?lU78Y{Vzgu1e=OB2&1_Rmx^DzE! zTKEO{j&kvbY23c>1NiD3q_E&WO8@(E-@%^TxBs3iLI2WI8P~T1kCzXg-=gXCYxB$s zgW1?>m&cuW%nTF+8JI+W9Qr440=X+qKL#?XBah%*6NcnMqLraEK1bRkbE%5?`Dft* z#ZS;Y?&}?T6&>VcpW)Nd7=q4qqD(n1630_M(7{zk27rA$AMZbJ!$uR^Ps zgT67x2Uh;(mxt|QFY=h_@LXA9DDEat#55H4q=55U7)YRK&hk+wabsi1s+BsnM%=ym z?`4woP5-O3qJaduZTGtC&aW_@s!+QaFj4BzA6X;&Qx>0-7BdbT5B_<`1tK22z5nJg zaDe~sKHU+N_{|{l9Yzbz`^}Kk+Jz;|FZC@n0B4Ym!;61KN)KV}u^|Ugn~a~?kkU|n z+Ev0D^M#R-m{J%084G5{Ps;2AIQ7KnwNPT%nbE|I#Wxxlxq`O~+;cg4wzozHOAX8? zN(~B}gk$f#p4qVO(zva zK7iHIyM{7G@7}(}Fs2x{(4f#1m(O%+rf#qUPPhpP!o%b=u1*JOUT?GX%~-1mOA`A~ zIa>bkeS$=Vs%)L09_M`>=3{zS_xo7Llg(ob=Pq9!_75!ceSws0>Z=lcopW?5IZj2P zAzrIE5~b3+C8$yI=4*4#DbYo>J9lCLX42PfdD~(z)%bQpq+vN~P}@5hI!d+4x0R{K zhElJ3xcAM!oEe<=om_reA6)Pg^#{29M5YjV#ZyP|y=8f09W7JNIf@kjKEc!C_6%E> zi-#4S*utc;F6cO!D4rFXH__*`Eix3_!=!e|H|u|P)cI^ZpBd@Y+0ZFtwRK~EC33j} z_MCopfG)Bk%$;JCNC`5iN$#B)cK=@5e#7U~YRIwKLT}750FBeEX0RbXZroZW_)Ru) zkalfs^UR+4*Lc6)mAkrv!1=87M3#iJ;9m-n0V7he#?Jzh9hCG#D4tF_d&YVyS+0hn z$)zr0zJ1r_UJ!R@!y4O$^QA2>-ZF8@9n`a_cr>|Q>h`Elsc5?iR5Y+S4G$-wVN96M zsn3tKlVmqf-kYeo)aVnSUm#bLX!{J`kH@|UJFRgEDVtNMqax&HT4f~9*J0%^-^pp) z+xAQFqNGQqjn-f*j<=$d3%2g!yI*A_tx)&Q(c~i~*N+ncob)ye0|TuIv=?&wW#0k&P%{sko7{PdJNMYQ%Z{N$d0>yId@i(y7=V zLtG`D1sU4*G@C+eYG`|C;j+r+T%S)SE@wv#MgDd6?WIo=mL>f2r7AFT35sFO~a2bZu!VMqS~U2=f{CW4=lH1&nh_sIb7%RkQ%n zHwZJYg@ZQ*TV@L-8ow4Xku#HQ_Iy1vGEnQ0oDenK4o@cLUQd3spZz7^mcpuOwP|&E zot4XFRc%SV0$akEV)pCAC!iqP>`P*y;~?aRT9VNp3;Bo11ptxq93?U$Cid2!D1Zs( zh5-LC0wBYHDw2Rb4WBB-5sm%_Z*LwC<=g%bYmu@f3R$O5k%W{8S*8skO`AQYLbedu zm${0NbwUU+Wl4z1I%S*e*^)iPn6WRzjCIB=J?H1XzxVI=y+8N;`rY5>d0x-+hgUVt z<-D%*I?v-gj`#6C-pAT|-~0Gbv%YnQdOXE__%}yXHOZId4;<5N$!1iV*$jdy4kTI1 zXdo!EF@_hKS+XjI0Dn@kw<}d`-!>k!|;eP>|AhQK1BS6&3 zlR)mKBG~&_3~oO}3vzGGO%UUwBUP28Eaq@(nBmG4wtY?-D*J3}>4MyihgXlf)?em( z{e2%-Z`*3sm*jJqdqNFOBolpc-`nu@@wc1>JRRn|g_s~)({$}ON97hvxSLtF&dREK-yBDJKn~_Cdz7$l1z+v{0jKXl(?U{bnSWBhpCJGQ7qJnY~&CFzBmI}9x_`+{_9;vVm5*9Ulw?Q$tZwFep7*(4t2w4 z$x2Lai1`s-F1HB{b+XG(V5jhZnIahVAe;=~cipnz915&mfL-SZ?oBQe>K;MT4gr~1 zbI9@b5>qjHzL00Pe`MhkKtrm3bFY7Wi$~u3gC=24 z&;$Vcrth=fACfJ#k{hY(={4%Ej%}5A`;lVe%^5~ z)_m;>05s@j00cY1@lQYa^IS}kC1rcm!SGqwstU9va}`5D9-U1aTlNDW#=pIA)wGU8zusND8ji-BoLBZk;*%kn9S~`5|EXaJy zz&FfPpw&enM1ZD8v+O$Xv$I5{Rk-d6?Ds?vR&E|+J1McPVXU@m?lh&DZhFq&e-CV- z*yT5e4*oC@RfayMx-*$f8Mgjhmk_-jnwa3_|HpV|0IEB2sj!+Y1cPmYr5SW2=pD5;pJ560O31H4)`fq-7^!NSd_~ivMPobfr(9BEl1_%E9;5Wy> z-63``3jF5N|LswGPs*=>6<6P&8^HVnto=fu^Sl7uiO}$Nema;Tx;GdczHwU>Y+@u` z1?ogDN?C)&0pAD3Z=2|%9PvyET^!qy9SkQGg6T-`=EYMyZ1tcifcH&0WT(kK4-qWr6xuXfy2VZps_6xpb*$=HJ8>VdffP4dr zqQr_Gf!K-qH~-TYY*LsVpo2JAq~#zGo3RBg`5_h`SIHyO1yrH5g3HxxaTRc70HBl^Gpw=6go7y?{sfvHtR6 zS6-1_=3v#*$Sbd4tdx5z*)#KF(b6)m3Xd`qwZ^KRLeg9^#|lwDQA)IG#^oSGP!u1b zN2!=8qQtc{SfdzCzEAYsRar|2Eio^noe6pEH+ru(z36I&$K@lfY(k_S zChn(3bggBzHen+Ln5U_GqCbH3;ZR3u`Gi$AcEf*Jow;!@$j}bGBR+B;;nh#0MjDdeIfGS9$NVin%lo1UHQY>inqG8 z!^R3w?xl79@ROZbsh8xb-$Lyg@W$>7a)5~jxdawCrv!MZZ6z2zQP>=GlFXTP6xkt= zBe&X+6H`*JZ-&4;BzBF#orK^F-^2_;X~c+URep{`N7XId!`gHg_W10y>GEvN9anHD z?}Oa%F(_r)JL)3Q4NKYj`j}}>L*zQKkMy{fccB(0^{IThOP)2SQ>Soo<};>Y*^1My z3SU8o^-)$P*wIoH8Ct#L$fDf8;&(&#$Ve%0I)=Pu-UUGJ<%w4mWD|_faa;10X~01Lc5d}g(e6}f#fR~( ziQlypnu-aeZXr;$Op{d{TKWPY0~_kw!FN>@r6^T{3M|*$33Uo#m@8WNUbVdU-t5-?)|8j#-|%lu z)?hJsJ17PQ11+gncKq2*bD}nom=fhKV?bVq_H?{oG!whs_IbYB)7>uY;}A%?Ci|lpi~yz<|Jpb2IfoUHVTl=yTWD$mXG5QTy~OxXkKD zx<%=iX9jDE<3gFj)|0yeKHIlSdMb^D4ag7U)2^H0+lHCt*1XwWsxTLqT-9?MQyG&*9Q0q+ z<-LW_idpTd$kcxY8o`@f*uMnS&SsY+xyWl2q(P6XmdOViSBnkbP!VWBqjd1+Xi-y@ zRQ@BYj#o|1S4xr|nqF%Bql$WqHR6@EG3_yfGtVCzb-k-rQM)-(8B?)xr%)zOIS4mL z7c?X2s^+dN=VWYZrEey(usSNB!;+2w^C2~&=?(>l);g92;eq<%rxK1EPWJ#|u2UM}5^FQV( z3u60UqzPdnf>KTW94c@GIVa|NoPL77W=;8x4{t+jiG-i-ErbJ17fPNLt#uN+->-La zqCA1%c+8NpUgf+HkZvVzz9Ii(OL@9i_T`JLpIHVxZbwcQ^_eEj4t62S!YZt_8AvO| zyVeASoGTe5x>r?FO|E_WHUl>}6d>ocasr$1XIR52%=%CS*}_LeCU>Kb)6CrPW!+7# zwwF^3(G57>x)Y^+%dR9xT*dIYr!C9%&r*leSB~7^??np1T?j9b``{CA2|LS!&F=J& z+53Q&OtI^u$X;r2-eM1UZNi>+>FGgVCfB%8OK1GrXZL)u!qbk;MsL7=;x|KRaCq~| z{!-BW?qMD&J;OqaPq{MAd!KEBABaqqrR!rao$+qf;A_!-vI%_bkYJGKe}HNZP-|d4 zX*SNKr8MHiY_fA{g;z^m49Z6tq5=sEdt-`wN_4$i(|!s{8S4s9?$vPa&Gp*G?YFF= z!vnN6Dh%GQcniLoN{+0m3{Fw@!t>-?*;wTj70p10AomqRd0$a3Fu6>I!nbLf< zhKn**T;7|!?HXz%?fT)@^Oxc;P4`X5nq-d`xhX4mSu26y74$L*0-kk9k|T_x?ufG` z6jKh1%oMJsiaZaxEc~WYWDs}m8s~IuzX#^zmDORpxGQ&#CEaVCDaLPhg3G*V(B^wq zMi6Ww17`Y}B2`3bywm4R*tO`BOq#BN2jAN{S`wCerRPz*mVJt63H=#IWQ`C*ik5hn zmAy`gK?>C95;I&{$HgdJt>HBq!tq&MPPtAzo{KUA9r`j&30(?B5=Q7VJN1Bos>rE~ zY7NjKl6;*lo%bUGjAW~{k&dJN!`bP^BKJF7;$cqV1B4I|1NO$m* z+bhCB{GW!(tzdj(LDKGGPHIKjkyx+vwGZh!h}VLS4IxAe{Z4;rUKqR&nurnGNMRmK zH>ZhvP?6WG21QyNT2U~n3_eV3K&9(~GiJoO%u02t&g;S5Ji$MH2F7>44U%U)Maei} zaItx)6fKPui*HRjyqE&Z~fvU{UQ^Ng}YR??OBo3B0; z_zoP$PEi)lShGg*@rr#V-u5fsbIw;|k#182SEuqC^22f+I4cwHf{BBbp_$_8_o>~+ zR9pn}BFS|H1>wB7J}0tdk!d{1%DD|(NtsWMJiJthb8FbIKa#1#D%3KdDs8Tou`pU9 zg_eFcYsM5=rmW7P5jp;3(bopjaSIbC3kpI;Q(8%2Ih>;P!f7%y zUl;(EZ&(7^Jn_u=+j{*|l#Bl59YX!r-%YoN(1Ylm&;LjXIHG$v+Q*#1)r|Zp-jo(W zzCD4C8{bRKjC{+yK>-A8LW9&1t1dx{a-^_R4C$tL1MaAm|FQI~_;00kI*WTG;?fdg z3N?aLj+4u#y++~)9h%n2>@oNB=$qvz>C9Rk^{titf!5w;WuwzjG=XBe5~s6+CPbAt z42N^HQjI23nuA5Yo@X^x0c!Vss#He)udkIUm6P3Y+ayxTR#eCNeBL{}Pj#Yb`xyF6 z8w!Sbh|vV!g_3a@7ohqi7W(ak!yIu{=faLBY+vSCb5M+yzkBT=I>|o%#iP)dXPu@}wfEQPR1j9KSx2Y?CCM1dG28J$-*j1WK5n`MD|m@SpP(wfHmW0WSsKHy)Y%^x-$nfqdlr}DE^cF?F**J1Aa z_e5e6*X|J4r#90UyGCgOme4P|@kpz71;5xktmF~TEw&1Vo#y0H_kC5rr~jtz)qjXK z^Wwz;WRUU^8hS<8RxF$dU`&GOZB=FwwlS^7j&`O=)%As;xHAhkbS_7-K>_!=7GhGC z`AWQd3&a#ZguetV=qN*N7i$2(250SXNx{11HFXOD82&_Y^~6wpEpieOU9?_%YTQ7p zm3uimOp-p|_a>8Ls^7Ix?)cct|09)xrNqhrUVHGdO^6cXmF|vuf&88#z*Vg`@P`riK z&6aE#hsT2+3?3U)eKA$>gM9alLd~~;35P=1xBZBFi2pYviM4Az_lL^zsB9z!VXM#~ z=}SHt1j7IlnFjcG$|!ggry_BJowFgmics~8lAn}0J~lo()FLN;1S`b) z0-s8sM{WutSwb>_CVp6Q8z49k3;`D_0g#IG<$K%k1gGsxA9wcb?H@Gkf2WE6tkZzL z0iW7^A2Y4Srw%|BNn-?AEK1a2ane~IGlRtEpfV~w@cpj z%FTq9Isz*KY&+U|pbI4AS1@Lmd_lURBHr(Q|5^TJ1|-73`~9ac1TOwKEkFC@(P+Z@ z3GINoaii{PolyC9dXY_OU)IVDv*sx;H#UF%T-5+B(ci`0cmD+YZcn7hV+}5@n9y(9 z1Uyqv7?wI<+q?B%r=8Ce48jVTMv7hDM%T|y1$DJ zywvk?9T+x6tM-1ra(V7fqoerF{EVWEFD)}Ih#7~!-i)+6sBk(*!LqsT-h}b^MQIJ@ z)Gi_k=;7L`-yHrdwqa9^8S#r}o&^Jk`=V6gvd&&$l#dZMNa-r_9tZW0evJRdUHa+U zy?_w;Ray2?J!&iKpgVApCcK+rR6(^k_*}Xl2s(EapZMG4!~bjYLFD-Y+X$@zglPgq zsK+*Lvk$It`x&)}L815x6>BBSno2d(^ZIEojXm~05Y0lzem(G{T*7zJS_aZW;?T@P z{7o>NdlQo1FZxzbIdInR`ni1@u{woOiD`~`k2qZ|5wicx(=#zsvuJBp92n&@UZ`%T zu|u{W*0-s9f4+ILCf#u1W!zK!Cn3oB6P|uMl51nkp8LOfd;_azFo3JwGq}Z29-en4 zFLaCWObVMRcu_X82ILtPf`Jx6H!B8l1IQnFz^4We_v%fM4G4+#LFZoGTtDpqU-B?j zS4S1~3ooE`TPhk$^H%*(?%$G8G!mS!J<66bEsQ+YHu}a+Vl@{LuqMX393j`9X%>;E z`taQ$)-8=Eh9!m3`M35j7+1_~a(Dc1)qo!9HV?Zev*Xc6H0`<;IR3{ETkCLaXZC&^$xEsLV*l)nHX#u%`49X(Ay(og6T9L+jj7@_kKEXU!80n7uXP!Efh*jJ`W)7`W`6pBvr+FLU-uai7t;**x8RbhY;5;>A<^c zFY+#{H4J^E+lsw(t&21`$G-Z#)a^~eQum~36bm2+F91H{MA(+9+XWW%qA37wU18Kc z1TsjXULN`iF(sV|%p&Zf`v%}RLUX5JfnaAfZWke^;WCT8I%24P_DO(Z=9yBk;dT{0 zASHzD-JUfGQLK77e6XpcRn2@z5H1%^|LL$hbo(NJR9)G9_;u|d0BY3_0^`{}0w?j| zM|{~4*r(vx&kx|~R(EN|D#S-7O16>M>mF4;2{UFfudIWeXh zoNo0#t!JsvsVL3$>?+Fo!Lkd!TzjDLu7mIEUP1cC_a`=6jFVuPYask?uRGc2_OV7k z-!J+~-#4Bt=dIFT7H_B6vx-%FrkFiK+Hrci!5#m@+xs$f@Uf43XeKgkAlD|Q@7D7n z6-(z)m7(l0w+e1Q%l?^y+t-z?RF;OaODXL z$kysRUO_DC_+AZ0;`1Y6JJLCZsz=yAbhKzCl;hv~NT&H7L_RJ=+*zo|*(^lz)a3qZ z3%g%e@*E%S9!qcmZsNa8oGCCKp*nEq!os~|JAm2f8JX8jDPk99RJN5ieK?^L9=<)* z4V{zXh=ZY>Hev!PXQK?Wjp$C_QR`Z@d6foB38io+B2w&ZO|;T>uEKt?+^f}n1#>M2 zpX1YPy5sNo443DOm%d5`H4FTzm@xKP;9rLWmpVu1Xb+qy64A7CX6&J+-4|u`j;v2D z>bBiQK`TUYb(X||2E8{4e7^4Du=c07?1>?RzzUl8aV#Vru4yWD)j5N6+8Ngo~a`N>?P zs-c-@CR)$@v{s77YBYUnRjkZ>`u50-u0ckqAonX>adij2)?*}m9O^jLEn(zQTBx~W zC*ym1-iL~EPafA(k!aOdnjEVskMqP!no${0lQ>kq!s17Q2$JGm2(4D4X`$|$z=3|Z zI00wxuW)CZ4z*E=VU5dDQU1Y(ZU8(=N!TiY>$n8gIiHmO##oji0Y_TLr_=e%WxDW7 z=5D>epLstPyxMSrextQ|8C$#rXihO&D71{&yRN9gcUduimt#CBPR< z`#}vxf+aJedH<1)GOg>o7KBW$TNr|Vx7P{5m|xyEbJcI#}ufzk^LM05|1%e2q za4^SzPw1FU;h9nfpyy6ANY^Cs!GXTd`C3%@zx_Bkf&*&PeGW8XnW^1T>_h08Ou(EW z@0$Hbe73Xrr_;-^D`HlQfhdoPP+{21f;(oX&hnd9=oqR>s*Rn%&9BHQeVuO@%rnf> zwsg)~`UTwfHcKc{rqhM5DO#NV;^U>dGc^vO=zzE=`K&j0A0+w)GwAbqzwT?u9`7wB zkFEgWxeqV@>m=A25s9DTf-`}6yd=b$9irSPxzl%m`39@%M;PS4!NgA@nP@p^=)-T0 zd2fCPCAK)Q%trtXfx1@n_Y#6XOSS&~!#u{yL~%RnoTfZ64z|OIz?|ZxJv$AKP`?NB z1&9IBtb-5f<*aO0Y7UH{dW-ccn?SV6Wlv8|vgx;3E+7zbBaAEzZC(o1t^M+2)D&%= z=G8vdCU7ZsPF#}D*TEwElxUdc;}7J{?KfYpbMa{LY4nE-duivw?eTl7(X4|jdujWeX#jIw#`iPJZ$>wPg4Nkb&Hh{v6#hzvKE z)8X&e3s$wz-zbJ*AHu)0H+_N;J9-5e=+Ee-wu$k=oTltzVW&NCD)H83h6?SBepmJ~ zmnhsN6~T@`3XVYakAhC5<4N%Y>Py~E@RpDDz^iJYL{$=KSG={yw-bMq42OXIhhDkA zp(V1a^abW(z&Pgz>-tqC5%u}&ZAb&QEu_o8gqzUp$j@{=zc6@$t@i=_Kk*|e^|;vbL&J)T=Re=O$cf+zH8E4}l026` zkVJlupLs$$tqylMb@h~sQ`WET=td+Ldq1t(6j$1}QW5EfmA(@xF?O;{;H9$Oo(tX5 zCbgG0uTmV+rH_x^u)lR@xnHK9+;_C@8rU%tHEiC#m3IvOMoqswGo*C0*ovvdDME_I z(ixjC6R?r=7NoEOR0${EaLJVHb+dZhUvv2*pYY(V+1PO6%ep5mUtPFU2X)@iGc5Gr ze>>?!rej1*oT)H+z~oVo8arL`&c za%1HTu;W*MY#0sJ2p2qFYbkAoy~%GON?qM8VTxSba|Yz0=K~hq@wPuUEgmrBm$mP( z+t~uJ)#=BC%`I6NZ9`XWsbZpqG4d$E_N_}QKf4Pla2Kj~s5wvY{Tef#Ua=2OE69BU z-gkPB4I=UDX&kKC`xvw|Fu;133W#BC!L!#Mrv}&UWqo73VRxeTN2>Qu-i*1|`IGH^ z5Y?5*kF3rfxf;xNpG6)3YW_Ss97yg07C$dfNGhw_6!eMtLmqol}6f6925ZD<2LglAdjHY+QLFNY>e;vRMKIk)N2W*2+1 z=`-(WI+xUEA@@xY9H4-^;h%r3ftro;fDabRW7y03fbXqSw8kC|- z1Kw@}3T)k2;3n^5&2H&48|Y}q;8oyJU;gW3&$8u%0x0r9r>IAyQ~>lDQjDKba{tYt zWyPxc@jsZ7;y0zVHX}k`JA6j>>Wp$U`U{qZ|CY-Td&B+)2Z`6~e^0!oMYXg@Dz}Tf zI6lDPaJo?^vMxjlp7?6A>nR5Zhq{pBV^U$DWnV$kmvkub%UD>}Tu+!R_N39WDLAw~ zo&QgZ>+k=cM}i#~rrp;2+^@>>g8YbV7L=ed!M}jGz$Hz!QSx9oUUidZD23T26FgQk zjj!k}*G3{0$g+L1i`K6n@daISd*zmrhRle&t9L$ZPm+Zt8@sCA8%0^+V-LF))A|g0 z>?k8v1_jpoj}ANE?+pBTid<*@{p=e%v(NpcPu`!%C)-!hV151R(_#2 zmz}px>fC$X85S7EF{y2ITK4U(HqZo`?H-QO?%VL&QhBU1`~&JRPme%$#4mID_r0h7 zw9M>wFPj;f?+IS%=zTuOb&%f)kCX;1L+tVf<9$?A!?xu07V&g{VVA(?7RS7`&d0yh z?-xt1<8c+;5?}b6b@>+|4mGj^1Dt=X@EYyB1>~Wc`|iZo-VX5!_pLP3869|qJ@MI< zm2n2C2ZR)e=ZdG>L`8`xd3H}G7po9&*Ue8`Bz8xZ<3;=s7X3X%s^a^2^rmmw+uyrj z=Y1qxf5$7z1=YDJ_U5Vm=Z6dF0huWGW#aMN0ZspHm=5iq6+VIm{;KjT`;Kzx+VMMzQlQ-Jx;^3TqES8HK zp0917mDqK+SbqX)@}^}|7FusTL{^?yJ=G8KcEl1eyxOnLtdSVAJoP)TP6in)1e3*H zW~jGkb~yshr=6n^36GOyCI-v18;rmb&cQnW1zHa9S7VUjx4XIkD{CPDCdBmoZ&i4K zBLBNW@8$4t#|LYD-ZSN8hNCwR*_B}NO+gOzJ+w>Tzgu5gP`@9Wn`S!lB3mXl7|-Bm zH&W*|2iYg7CRJ?;PYqmLj#3&wJcNyHvX!<_Tk(GS6XHR~DM{h^g(r(Eij-2__rX+7 zL(tDN6Hp+pABV#-_8BBFM1mTlB`>C;h7U8eN6^Hi3vI28%e>lYHPXmhKi=7ah@cCpghU zwA&cg>>pABRg^3mf(XvP^)b1(1I1*ob%U9got*_Q^$uxrYFoeJr63Hq5W_I`zGzCy zrbhTUD9hqr7?*y*H?oPXoMHGrf$jJMfI!Z9k_L;&OE+y=jzyIj{ybs7S}<^;(84L$ zeMNhmq2_+9x$$wb$z@5c7M0tDvVpa(SvCtJnr=zVe`%a6U%$8sv=NztW)t0Hd1|Ny zbtp=uHUKH$GOj-=Q!(gJ8HYH2C~3g%AJu_<$QQ{pLB%H0 zB2$Q`RUTLTah!N4BcY;qL+cvEO)4ZgSSrWnj4rbEr}+NVKl(>W%~A?eJ!KO>U2j1z z7)o{(a3S(b!Q6uRbx57GOVp>{F60$*7qC76Q5V)_Nj?@QKCdE{ku#pHp;RYA8XQfB z-3~hX&owdyb7@*qQaz8>87B6XCC?N|yO;kExKxZlaTJ=+u1))&L-$N(eKKptQvmWy z*Kc_T(lZ_WEWSeW$$wK*1KR^a;5FbkA&=29XIt>Af3!eDUr_(tH`a$*K6DKzcBFIc zXw$$hN(?zN0jx(qcw!t<>VM=>-g~kj`Jp<;egkYjnsyAq6nMonG=`IK&DJW|@F5Yg zvFeAYsPqk5J5a9bP?t z0CFkM!KaimfY>GzVC^A7P}|f3W>el8#J##jsH&kUGvO`GJjX- zIjo>%(bq*cqXR+9dPiH<;=?qA*5J%TFeC*E!%tMeOS-mVPpODYs(K+sK8l>LzLHG6 zOBa5~pNU99LcgR#KgW3L20dfA`e`---Kp2`iQMsX6mkptJ@Ys%GWe|p&4ZMzUe0p2 zz+HvPr^}`kzGPIE>A&pNm6d+i;*lR<)3cel?vdZ-z?NoTAI*E;^Bxr90+!CJ>|kw4 z!kgQ}MUW*^k>ML8My*6o@?AEdrA~}pY9+Fk0^DB||It6`xSpP>9w#cUz9v~`ax>vO zcg&}wC0Y<@JqS&jEzMF5XNm4jJb~_q{L~Dp{HMypkN11=tJa&l_{hBgbE`SF zdbnsR#dFEPTC`D)W6EsWErN*N&F+I!u5=G0h!KtXL1zns9`|mb`HXXiofLVT$A$3~ zHIW-e9k0+^$yp~7AdM-WODwK^OY7bBfp+eD zA6aZRdOE~f6IC0~`!QB1UD{nMzFJ%JEaj=hFjtOrzd(`s~Cc~E5mPqsQ z!I|ml`j+dlDq9}mjC>t$huLMMm0O=LPHjX=i-osky@Dn_!Ua`;*%MLl;t#EIZ!}hG zIv-qFuBbpRUu0zHIf@>QKW6dp;Z;9VCvowTuam=gC(MX1I5XlPdt;GiF@CZDkq{mr z5?#R~{DWzG-`m!&Ds%L8hvn$U(FD_1@>km5RxP_J@5QDDX;RaP?*XvL`QJtGW&@`Q-j$F`{ek=MgL;nYVsl=Ogi?BSW2VaVAe5yBki?gGHN( zT%O|Q&?scdO9Id}P z!Z5z=%h;+=V^tp~jWrFEh&$yQjrn6fciX>@*?aZDFV?tI@vSH^V0eDg_=zL5x@}~H zjWo@^&@aSw2U*edu)FIr`JKg;!o#U`X6{K-_x7ppdYgEMRm+xScOv)H!Gp{P>eMe& z9U9mN2Xj*xaO1cUZ%R|cpmLf5zjI#KgGLXheR4G`Cxv4DTbuc#Q9|I52@PZOG>Sxe ziAq<|w2O4PbD7ud{jo)MVP<@7@j|H=re6sv%g+?qHjC*up@(_c++W`NIo#~4=wP4) z7PjI?=7;uqD3tIW9kw*NBb`K$2z@;1`DuaAvp@gD#IH%JUXl|MTm7PhLj7UUs*{wJ zp5=zMKOtsLytT_{I^nXA#E!&gHW@iVj+EkBD#C=KoB74o1L$@2%9Ae}>cJh$LvCI1 zteOn-JS;b7SgAAL+5kjHW}@90X5ctI#TH>Y(qumj`z5wwI9%6PMExu`JCvldWime6 z-snrdpyQSKtBa=~L93Y?Wp^YPWV?6IRab#t<7KyK1BFhwfa^un${F~9(P8TkB4TbY zOB5{a%O>9mFC4d**yonJfkg;hKUr(8s3ASnHkvTHva;37Z{iK9@!z^0D~HDkEq2yv zZp%uIv^g%3=P*K$3oIPIE66FE_M}BehmsQRO-5qC5epcY}0Hk1q+y36$=w~eb-s+^iwDJ%@!?N3qw6v@1t#Z;vwfweB~JT8>j zG2rSIYp9v}i2b1Y#qG7@6M7r%i7ij8kQM4HmevXKd?!9P-$Q_%c<0`iMO)puw7%Y`GDh2eT)J@ z`p@$nzd7`bka^zp2(TSE+-aZjf?TZ2fM&A(C~4atdHc^iiSxGppLvp4u-pEu5M29P zeIV>Y5XkCss?z4Ew}}YyJH~06(pS8os?y#$xF}N>dbjAIn>~*!a+j=qn<0&JiT1Vk z^xaicH4(ET?g=1eftGF}Ou;866f~N7pOg-Z#_aHRbsM@!y)rbSBIf&|=D3@comrdW z+qiKO2UE*+r#k&{tFMFuQy4Lc4q;1z$PBuVpEH_QYAbm2 zsY-t~=L^r4fPZ$nP6QlQ-B$|{N6_i`M+gC?Pz0NYMR{_O7DnpkwDR+9gmZ03o}XIS zW}Z7UV$JjQk0=A)$VyX_2M1m@?es8(?vr5`XdoovY#LGll9mt2-h`(*M=8+@$b)Vl zPl@08;p#o@V1+D0mIM#+*4fNuCncHW&@b**B;G=`Dc&}zS?M@ggJWBrXz8*c>uDNP zggMBF#aS8zDxNFz$nA35ax=5>Os1N#%(J3S8t=r*07!NfLDy3Pnra^an!D{Zu(*-+ zZ$DmA-^DgmkfjYxeGf9Dm^cG#|g) z=u+M-eWOfzN8G@vulFx%?es{r1c_=sRShCYIrL<&npnBC@6z#T*pAOcg=eRe&J}4c zyF6d#xbp5!z>YYMbs-x_bm4Y8l6*@}op?IC?`?6X{q=K2H$O_B?$7Q^GjWDD9O_*k z>Yr}7O%%I<5Yo~$unszt$LUsR(N!BTKj#IbeCQQvN-J016A3y4i&mCsWxEgm@D+u9 zPv{_e$@z+Yu1>zXLwrzW?~>-kn`d;fmR_AB3u)R5-^+5A@QogVoe}JvU!y=DSj`)H ztaYEV&}VGy{TtfnChWFfx)9IaO>efg4{PmlCh*z{ZE4&Npsy*_Fto7eD8rVA-w3&~ z*;U*-51I19gAmzTe*-;maWZWZdv z%`Dx*C|ADesT^PJ-6A?>MX_VxfFGOn^n_nJ`@$2zpo7-`K#m7Pd!&wo8e9p0zra{~ z&9*Se4z?-?vR6}B0cdv0jZFZV5%I`Xa48<9*`16!iW*`o)4ai!G^b!1f-fyLfRl{3 z<|2i0hx!PoY}vN|os)KMbB(mo*1~*7#&u==DvKYO zKi!JnTij2D*A(aEOHGjKI9Z?AJbr4ETTBt734A!ym&k%O+rU245Tt!bgMs^2(egva zm%3gV?V8a@LQ(WhZ`?Vj{1zRHIyP^HDdlf)M@xxNoNdfe9m2?Rba;6o?OpsRqEE%* ziW1hcX_)?~nHawBkYuRWhk#GH5nJ|Cpei+Hp6PX~;5*hNc0AwqUT2EhIo}=xU$v5gWYUXUnjv~}2T2a$ zQ)MDNBtE{P&djT?kh-7h&f+vnswF0if^?~%p*QX+e4>I-)-}`^tcfW3M4b&`>N-&O zw_blH*m1L`=)u#Uh$muvv86K}5^jy^51x|0VKY&JK^hdUB>;+W3imF#dwIJL!$`hy z#T7~s-5%~-o5zzTOSl_o;sy+foV$wer1fXBlWr9rO1cwFzo@P8rHA3~M_^bwkl{P@X9+z@iQOJ?{?t3zmJ&QoCJ4A~oP^%G z9?E3>nn36IqF$5>X3k|1KYY$zS@D5_>zQBKC{E5GklEqv>`icgRwdLZ;cv-|;(z}@ zmg$nMW7@S4@OsL#&IHi|OFBJI9Ez&VbMzeVJ|s+O^U#W|I2OxuHu2|C}PPA=m*Fx~*W;azN zy6#{QFGVS~Zr4H;EKG}^6m0^~LAF5Gp5B%_pYJEJNP6~nGZt~3(#oC&8rbvdQ5DC! z48?q1WoXu{`GaDKX*I{Ts!?V-$0%^-LENNlQ-+57BNWUf`Fy}ra$sTBylDY`e9bM! z+3eRro7f2?P3E%8V`SS$rUlLW!M1Bo%Ft=Y-n)JwfsVk0n%}i-xjnrs^pj!ML*RmJ zjol!A7qOM56(MWdBq?jog`~}OWx)MmT|(Ef$8`Jns4|@&>5qM;sMbSdaR$;RXm3xQ z2o2rH7N>NACV|M(nWbp>@k!(4Yr*gNUxocL89fqL&^VW*aAhmaCK_2wSXJ5d*6G7# z0WWD)$C#o7l<~j9512Msx%~ zXcBY|JD$HpFz4S-n`}WoD$g_%7j@9_l23U2xFjnyana@?!wU5sFT(#7AG5YF67dx5 zC7uPdTMOeR$(ewZ%X=$AFTuLr^g@)#OM}rUVL@ZYhV)PYGUgWaqBbG04gUpYaJC&K z?x!@V2=a#NS{4+1J+w!MYhLW>@cp1qEmh5{Qag*r2x)Y;3mP%o0fTv<{$Z>D~@zI9{dgUqqc1c&UPMqArwt$FWIjVKAKDVkFFRWXmY z)__u{spXqv$kuRV)ZSvRGoe-LqY74hFL%X14d5MATFSi-yyOG?BZ2kcNjC7j%^#in zYU`dCJ^s`}Is95O_WRu6rAE)G9cbtAWU(Z*uH9GymhgGfP+&l5v7ZiObEiNe2_AHW z&1sq?70Id`*~7Sb@?+h@vCn%M>3**5j29ZaL;FE=bPn!t0t>k)x->~i52{$(0&nUv za(vg(#~J z$A0DSRC{;)Onj!N|Ivy!{z;iFY1-03wv6jcjULDm`MiVLS}$f&oTz*~SqDyGC6J>p z_uI{AeoHJE`L*_}N4ni^7M(U#%DIOV73|w?mAi`no^gUa1w^o z$Y;xx{YM|+=!Ntze5=WNH%>G7uZ7Mc5923d@dpyd5rWJ!6^Si6y#4#C`p7f-eH-a6 zKJ@s9xG(0>=R-u!ERV%JJ^O67A#Z?CR=^sMCA)Rez3LXb&Sn_g4oVo!X2J5Nf=VbN zh78{pJg?QH%*Ng_dKCMq3Xn^}IjgS@N;9)9ncB2nl;|d)msVJ#l;f9jX`lw44>{?l z8(;cvWbu@XQM?26$(o!00ljzD#`o@>pNj;zZnl8*R9gSW>EgDsDZ~h$P9HVU%9g2I zz|SB;4MnKd)BecHtp*rFOa)LCDhlw2ABx})skZG>Vg`Sj{b{>xnz9u04sykZ0!;u} zwqS+|h;EUa6^wJoPin}dtSbFrW=Bu=Fc6%q_HLX5b|MYL*TaN!PJ}W@|LFL1Q zskp>mzVZ1_O-$dmW0@T6oOKRQ#38?krgNx6J;kbgmq?mcnkEG4IKN$hO0=U*%d7(~ zT&HNmyfM}w}EQG{QvU8BFIywF*LU1a-V(}S!@i`}9NE51Hc30JRhO~(ZAQ&*5r3}4Az z)H~Vs2#=k|95ns7(A9pOyxeWZN~*E;pM;NK8gagE25K*#UqG3+X25na1Bd2|P?qdw z*s7))(1M zB3kGwf2&n$6a<2;dd=PcCF;uJ`cIWefbJ1W*yV)|^*LLV%qSeqW-NZW;~!J|$~G&; zYZ>D8mO4rmig@nDLpHlpQ`xsZc8;s6y6W>weZKOmxfx?FT{3MCEYu1G+ z9ct{7B_W%5IwT(s6c^p^8~45_uOA_JbT3Q_{9pk42O`X)b-ZO${=N+11 zacl(^2E=qZP#Lh7to>`(-6QaV}e=rL^YeylDp?@FhI z{qTv=y{^v=aO`cXcV^BCgfRC{9gq^$(F-NYA+-d>s;%(jfm6wQ-d zpG%T^UsH6-!opv>JGY#F1he^+l?Eo}J!U^jh>0Ai*)<8@$@K5rFsRY=C6a|*PeD8j z=MF?iDiAu;ZhP!|K4Ca&k)H8k$L%hrDJzHRK^dAvzR)@3$kytAPt)`+ZniIGntUdB z2U1!BvzE67zTE5SkS~cnTKzKq!6^ZUU*mc!S>^o0{7Bj}1`wtpwKlB?_(9eKI^y$Y zl~N*o>)I!+#5Mh~rYcqV|BJjgkB2($`$n~sqU^FvLPSWmtWyagB-vvsy9r75VJac( zgiyp(NY=?Z_OUP7vS&AA%{pTlgPH01c0K1g=f3XydanC*&g=Q_{Nd$gX3Wg*`};1R z<-NS?RLkG2G0t~>5gKD9%I4kfZWr%UJ!}4fs z*#nu$;2w0*Zq!ax!9}A}AV!&>WaRjf$D6vj%RK*Glw$rt73rQ}(ZX<7e1yY-=ExCs3;OkEfk!}2aI|T7M`ql-C0(M3 zq|j0uQy1dqp@w`I0@q(q%>&J_r5ew@k>7?dLGR952RpER;0M4@Cib46@H#tUIb!kt zm}0_{C*L{K&mzxyjM|88`8ajj`+E#O&g-3_u4Q-d?%Hy?ao5NH))26EyLx-Ai|chr z@nOHoHj!u`)K`EAB%$yEiw$c5W38K;Q=9q~8b1ZkH$h|{@Y5vMmiI!)^Pv9>8kWC6 z(a%-gk6$_RTaiX*X5qB2hukpXl)MIJO{KNN=Kyt+mZEWL1$lDJIx0r5b zt+6y$FHs*C`#NN$Eh}Knm-`7YYSeyd z3%v**YMI6rN-rQR;>Gb{ib+6R_r4bIWq(W{?G0$uUVAPnj<6);d5T2Wxs~NzTxwdZNc9qS0XX0mpfLPQR6+cpih2jM_GuNzS_n2qX&-|ycs zP#jw}lK5$3ttMn=%c(tc+_U3I<;7~$uU(U2sw|;bYP;5STfkG1mgQ#e%%;^dG8t{R zaY|L!a~nP)EqUeM55gX2*7|rE#)-lm zR$um^!>hV&3KuxGLQ>5IZEQ85;`I-X5RxK!f|^;}ORsc+cA2lV1Z#o~-2yr>e*z;ebAr&@lvEHLC`Cdr?+2@4s|VA#-ZF zVGfvxWz+O`RwF9~Kw2UKwyBK7qm~i9OvnlZ&?e<1o*4LtZ^?gB$A?_`YfVsujiSqf zn|6bKhH8mTZCJy@!mMmDSr35Jq1=Pz-#I!Q&!A^{27YY5Jn0)g$M&Ip-?hJAlGy_P z>KZ$Q}N&ILM zdb!N_?Rmk4k(=LRhg*lIdWK~xPn~@;Ye*GmI9a5{U|RBX_4&#{YF?nKg*1jyiCu>+ zKOwGus@|b!_cYG@r`q~A3VOeB$0idwt2vv*qTlm7Y;^nFyx+i$iQ$i zJyJN+-yDOst^OUv)%i)^(hIuc(es10iun$hAcy#eiyqXtiFmgEET?pei}D?{58!9J zst#Y+pj<+&`Vw&Ca`+RWRLPlxjZ;f*8GcQAB)9J2oJG#JzidQ*G!`|7Uo?9lm&B(- zEK&=S)Z){-YTc3}V^KOX(T_}j+1Iixzi=7LRtwKvWU5(X=Q#8N%HXE7X_pBi#4#+zI>oV=hZzlD<^RWWbs zg$pG&j3|iY)e*pySNU6jItQ9U!XpbFRCYT$QxP6D9@rWzF|f0720o@1AdsnKtnec0 zdGz`ZO%Id$HrM?Xx=bUPT)p-~7Uq87IFvH^iMpH}KR>+x*yFg4afQY1oGvm=T)^wFW z40Ucu$`FLa!rcVjhp2&tjTi#}yWBn8DjfitKBY{95u0N*KhQ)$m$<^G01!%U3X0zZ zZ4Da7iV>B=6+%VnP&M?)ppE|}4YVa*#em%}ZUxjN2>dc($ZZY375ZM;ABNfc$mVM> z5SA<>LE@e+lZdbcpDI0;tp*mV!m8{TUQ} zw~#Q1q&k^YS1h-Ld0F$ zp@JIkSxag8S{1^&Xrff4TpQoDQ%4`po_9J52^fxrH3;^rRiunmE#dZ#NQ|Nc4wStf zeH-h|14G;^Kzj>R2U+Vpp_KOtc(VwrR7MK^C4I6oFJbL0zv0IH~~e zfg?hRRtSRRcT>meh>!@ts(D2Dklo?+u&o1E3>mfhPFp=4Zk!NP&mop^ZWHvUnCVRV zBrh^f5xegm1rmViP^TsVFH18lHctJ~9sdM-r(@iX+RReAfPBUdMqLWQdT#aAk( z%S%(ZMg9q|fFbYg_0s|GU~Hx8&>3vla}p#u_gC5Mq{QJ~uet}#&F>CfKjN-be&zOE z-N|`*^IlJ#QxCnUtmI(3uHReQW>IAm0qT6%EcA!9r(|SQhiBfE`qBHknPatuDPv@x zy5y?5a(OjP0vy9O=~3}^&)F#P3{>Pi`N;l``ie%R%>X3%^hzoj|ad)lTii!HPTi?NRcwd^ZH@ z-wN9eX$7b!4yHIPx99N5+rNr$E*AC?N_=^W^(JEjhQF|?jj+>~XnnUVl2-S4GhDX& z(s=0QiC7+cYUPvk8|P7re|>Ody8J>oHRU_T$lo6n8*G**Jc}}_awAV#+mU-uBDCu5 z{Q^dPoYBRp3kK3|R>tx065XKTz_jP+)6Y?mWh1!V<0|dXKDy-xm*Q`0zUlczmUV2Z z;B{+rCtN@OxF*S&`r(6;q?_oo?DMlfN3?Pf_l9KLq@@T4>L0mpBn~suA4fA>sw^J& z+gT4}e=r#|5;iKs=J1}WkD#Vkpg0g)Xecq3t(cIw-QP2|ypi2PT6cLnThy2L_U`jf ziCJIt4EGf{rlr#Zet#*?0_?yH2+M4cAo@7rZe8!YVvQEnLvh7{h+O#o z0*oSKw7Q90kRo{Q^w|S0AYGfPKw`g3F)nB#oR(Y#Ey00Zf7@pss6`Xr_I5g#`u@ny zr)i=GrrzWagN&dW#K2aDE(Z&zd=TXG&w{Rn8{a3pP9$Mv}^=au@*)sC?jV;m{C#*2`X%2NpwfcNQbgBVhXhs=6p@i^c`afN44S0N&Mpx3s$szdZ> z+K)}ZyUE=C`F!&6pvAQH7)9m2e&<8pTWqY7i?1cGoc;Le)O7}SWB4?77YOZbgjqp> zQZ_S1>>d~uhpp{`;~V&&sdolM78hTQ7DO8>iM_k)uTl1On&`e!4-P}wjfjxnxmL(c zDQ)mjTRr&?!-pusGn#^`4E@&UpFjd850<^TJ-)iVq}S}ZJeC9jlCCQm2R6nNE=Z!a z(P;ARfwk2&HA`yWaRa zT-W6sx?Zn^>1X|m21_F*kel@b1;nh*ort{1AaKG$(&PEsZ)PX^N#fb4hxWN6bLdyl znRnCd!K#QLl5aSI`O{SPYv4Mm0lhIa4-#S7~+9O`(30UuB;Zo7wmag3{YXj@o~#9sbfQ^u>e5h(s6oi&42W zI=g7^9X*75oOddC?{9ZRd^ZS|ZMJgk-*To^htDH&1;ZcN5d#}><+#;8P~kXAiEq@d zBKExeRWtaTP&l+DK6%XjU`C} zk|He{4wl5~Kn68;KLLKX<&aq7aU~hljcCUTB0|^9|5cMQ_VOeKzP!Hzzzh>(_g(L2 zcO^@#@9xQL9iURz>J$;|18Usa6u7C!8#KQ>i2Z(C2wV8W@Bn>t;FE*x>8vXD^LO~& z--T^6+^1iqRU){*Ek$LGQbl7obYs*?OGl_Sd9L5r1UNFfI8g419l;&QGGr*y0sSgn zh`63L69{2ya*NltdvScJQE3IH@n9wF{+`|A z?w^m***9qDSvOgSy*~^=f}G(@NxV=K%YoeS5*29i(~)6llm?2TdvKGYL>OtErkK@T zpx6<|8&%9>URN>0Z3RoK2G*Y}re96k{C=RRy?oJmGr!ui_X*_U09|TjihmFUqTpoU zP;fIGsn=j4F$?G$mWNtd`-W+=Ux*8Pltwv9G}Q73ERnlE5!!>&7aSwLqy}7N}C=rF9<6hr0UBS1syE=|}>w5p18k5hf6ZaU}JGuk04SvCjE@<`|&skjb{(35YI-Kj$v+@+SQns7tk0!>Y zDL)YNaqBWVZ^ubm4(>%VBH@@wLBXGOj7u69iQU-HbwQ)Ee({_0@e`J<+9Jo^qh-Ih z+#b8G7JKa%t)xx?ag&BUgOH?1;-}f*Fjnf-j_e87yCSPCXCOMU27P9|j|GSpuOlbUH%e z((R7;WwVZ5FKcvh?jEch-9XTW67mOh=tg5B>y)MOi`ZTBrC{@yiCee+Fn}{!ZkGV~ z!0o8PoByuj^MC5O9LoQ`J!brG7@)ArR4YnZARzWw3IG8aH<+T2{bn0Ipkul1`_iY` zLA1VfXLe+Im6kh5aToUlgCD)uIW4#uC^NKoq6HIWj^j~Cj2qUs;Tqa#eua?3P=wYdn9@6?9^cQ0GSS zjl!ro<<(A($Lk86fh5T;LECDBx+U&r9plLpEvDnXA`Glb@{}&AVyjuX{i?QF1F9z{ zD@EeZ9@?`L450=N+i;lF?ugk9;U1&*lvz)MKIt5EUqywchv|g<%Agx+HX(QRSn08` zxO;i?6;ZR{8PwivJjh>z^Zp`=>YnbO!Swc0uCdMr?S|4%`AOo3Vw#gk)a!#zXcO^f z-u(O|C1*ou=zJ1e&@KDt7jf5Dwvy>A+!Sm7z!_AShDMlT5VUbWaVp_k@ph=pJz>1Q#ek* zHK?LPZP|$01?t<}ag=c#l^WUBP^%%t?+d@0cc9fhIt7kk`K9_3qgv0bm?^4U%c6viZb`((T-zrA_?U;E9qUPT-+3cnWiA)Bf&D6PQCtjkh#&d;aEQa)$Q z|6Ln*Cq4Ei=fk{c_UbzLzSx!K*-IlCr_|V^(^6~ey3)#hhQxv~hS~FeD)M6IVoUP} z-An1)oV(^eZr0_stnu4Wi0d??nso+cplPeDuolnPzN3HXep~`?r}nSq*wnb>&t~UG zR@>H0L6mlsmQIHt6#5j5Amt&1sI{Ao0w#t;N>TXzK~AR#d)Q>fILo~7!rM>RX3`VE zeEb6QD_Sd+x;VL^fI2gwljTD0*ew)Gd+nUDY=tuen#-(|^FV+-l718fns~md3ZhZ; zN~=WR-Uv=kj#rPigbEcQ}tKi@a^ElZm;qCwixem4;E)R`m7M-7#BOPX6Li zhE=~)Rqkzc$a3yyboSSHH(yGC(sKXKue~F)tW<8kHW)hs_IYWF4O;^Iao1bPlOpB) zBSEuPB2krArp84+td)l^9g$4p+~)?T?^oIf+|pMWI-`OST^rK@1H37sJ|+@fBGarc zV?=Tcm7n4p^yax7{^t3|Nej1lErXWtrwn?TC33{R>L~1kUFgg#LL3n1#|hjtS_#;} zI3E}!5g#}|n#jF&Bj;I_8Dbpvp`xQ4sI_^`QD9eR{mq-d4MZ;hoP>2-a^SNp@yzsU zNx{EWpnv)&5({4-`*wz$JBUSAxca4%a=TRU+p;{f3ZP{xFQ8Y{3bAOd2e5EbC}s^8iAf z;_H;Xf(J;>g2B5HFDoHbC*i*?oo5ZW}e^p1!aH(r=A`m$pza2n?XkCjJk1oShN+OWTHj z^!C3h)gws4&Pi zVVnLc*|f+J*j`7b*$IFck?M+QLmj6VD~Owa!oDpV**PT&O^+Mtp1cp(F5 zT=kp7AHdcGm4bUU)WG)OdlyV27s>1sbP_8`rt2RjYoN@&)kbGLjwJl1f_Uq@DTW^F zO1p{a#8J)*0U?tj5;MI)ae7}lZ-=c8+YBnmrrRibJc>CKH)q3Yz6UT(k_4kK;OKwx zkDn(+mq}Mu@-klK=U?R!{1oZU!5}_%LD z_2b`XSog%gqi10W$|P809Ce^ii<9;VLHVbd_0O~JkY@Ar<`Fdg2-x~h(r1ybs4<8R zEfEPC1Dt+a)+vl@2-U9>HZA=5_igUPskSkm=jV19(rv(?NR7Gk8pW#99!0m4O(f1co_BXVe`qkC0W)tTn5)N|@=@OP6&%!)awuet zD4}fLo-0W`y1_axf*YqxxypbSh*J~TNLibar68XX+D{IBnA(=kF<81HzFnnLvqjJSz(#1fc_WUGV%I#@f5cRafPj5!CeKzLxSmX(byk9~Jcqs5O@YRGALuV-_Dg$+ zR{4I%CV6{Y%Dx>^$INwmRPN=4#m&_pdeJN5QbH0r{?9bWE>4jX5RnHij%z<`VccdM zAE!!U6sZ&0t7H1`mcX8IvU%lpGaATekH#|xM%Yt+6MJ~pp1;`{JykUUnuwooH`u+i zj~VZj9^!Rm$vn)fI~`6xsX!F;I7bO-!V$4*N2^9cpYWLs6~N3R&ixp*kZf;xb5!_D z{vlC|(1%Pd!8X&&NLDv+AwQiltBb@)R|xT?W=4l6-*^Q5*VyZ49j+FLDHqS0w%rK6 zqN%!<6c8Ao!mwzfDe4#NY8CF4NLhH3tr%CMYMx`PsGH)*YT%Wpm2uWT+RRL_ai&(N zix({d*Tld!I9?`#o;;Rqz0}9C;$<8PN9f`VVUFmmz4Y44*uevMJ*<4O5gG|OqQDxF z!rE-av@Ce2UnHKH=IPNVb=e_0)NkIgE&2TA3_FIlfEj_ND?q(nsWHu|0gy8a5Ds>t z=%$KWbm5nuQ&iW4EY;enat1HEp>h7CR7u3Sv8e_1jiThhw(rGram&c!ww7+zR$wEF9=kVUM0qvM zCZpy{jM7fNJtVbi7dveuEB#3%5L(h&)B)#x7$xp<+(IaE93x;AzmF@+t`5Jt;8JHJuRX8sROLurD}VZ+^6cI#m#&2?qYkP>?bANy**wE(5*}L0nbW4{p2m;# z?CQfW4vmk*znfcX%^$9bb92l4MgSJOK>^b`4%iVvQCcbe3`MDdes-ixCc>7nv=0&F zc`Q4uu&3xy6fDW{evI2~yG4ihL0vBgXAZj>y?~Z0HTI5W+ylVPCN#tkuo7)K(xi5r zOY;UR;1{3jakbqKDky6;E&s?K0z+x25o42y;beAVsm8^M{^!%-bysQM*J?Cp+*D2N zBiZpuLq;2Rk#`Qc&KQW#IwgyHRL!8lH$rF=3)-ms`lJhO`XkSIr4?jC&Z#*vTmSk{ z=oEFY!mau~wdZZ|M{mX(zb$|2WlL=@JTqJ2Gme2n<;O$DO@pL<*f_Okx%uDoUD~j^ zUs#i(2RHwkGPT$NcJ%Jy4gTdm3Maz9og>}@zG;H2I37hC*>7bc6mT9{7io5 z(**5$H;E>9_1Zk5Qe%|@?q$uj;>pVwm$jr@0*?GTWE*jC-tPe5Hy|-8un?-yY>d+l z)IDDp{z>epJVn?nUf1H;;V+Vcqvr!g7Qqp4GIvKbGE2jHMRZ`0>`eH~8k}i5Vp|S% z0cHr2kW((c+{q(mzxxA0J9x5NQPJYD zjFY!pwYB_^ZOGTbBD8dAJK|LBmq{0u%z7R7*y{KN*y(er6UhaM;<>PN9MMREl-4``JZ}N(!gl*cyi^T?5kts!p>>0xkQ=h~oO)^U zSm;mT-J%KIO3qiL(EX8o=Q^%|PsEj=>Z`EJO+h&am@~>!aZ4^(74tByDjMom_?js1 zg$l_nmS>*(BRR32rjC`?Za$l;SAvI)^~mPswF*lX$@;H*H}T~aM`PQlE_LUNOeyH> z*6j6~Cnro^_89mEGZE9XNG7`U7NSPXH@^@oaZh-*<%c_#^%K8%ZY*~_Rfh{(5M`n? z_^FQ)>H`+Tb}XqLhi-egl^$p!<&xjre)CnJp~AbTYj*_(d6{ix;%i`CN8VTpe7{`r zK;6{9K#$~fjC2wLZSe)tS4r{c0nTkSl|7t13Q~M0)>D3+O?9Hf((*Cq@Slb+i=Yft z;%k2-#s)fuJPWga!@}ffdYn^a`vlaC7Wt5Ptg8ywg5g~P9!aZnv`v?XNavN^lfjRo zZll&WuT4ryvl`pZ5_M*5(qDe@Q#GeJ%@lCo=tDfpk&3~@A|8`gs=nOzWgJu00u0zP zA8+x@aOLw5Ywht1vHMp|0?vcW(*2@pT=GAkcPy0-DAlWsV zxS$z>k5OO^8mvQ^7KC9nhk2LsbETWaZ%MhPj!t$jj@-$LWhCG<=ukSiKCU`(AUD#e zI*U3BzgrN!Y5l<2^W`Ha$G##H?$Jj~JQ6o-@29JbfTQymBQd<0HGH}>z3dYuqs3}B zS|Q5C%uR9M-zL-V&GuM@7TReIKYpI~6q%ev8*$2+1st-m}0Gq4U&FV(k}bk)mVUHG_4-7VP( zqU$LCMIsg-z}7zC-_hp|^2myP)B*_?Xw_yVfLpvzp@xBDMbBD5pWM|a+SbXA)2O%y z-x*?e3ZL(E+Y?oWL=@)s-D_@@siOAY`&ba3utP;fOS^L|TZ#Yxdu;dK#}VBFxd|!U zS{BiXp5oVZ=LQ;vwKi8iiTrY5cT5sV_B5C-$22?uNq_WqF_fq>PKA^6$L|!v4{&qA zT1k3)uVK5W=`lc?2YCDkbW{!i}}^U~U~gllnpKVwZ_Hc6i1e8_nI1^m;O+^e3h#(kgl zF6GIQ63A#Uw68BqVIEs_}Q4+kMci!h4ibt1Hg~m$3qXd^0efcN zkGubaGJVaDxBFX1gY%eg5IJBJa{9f!KSK6twofHi7s&BCrGerCr~=Hy#Rqp!UB*&k zNLN>khHAV$_@ooRjwXnr!kb>@AM~h=zsh!BJ$9#ja`cqO>X#ZA`4#O`s(w6@h2n$U zUcX1)Vdb)`0;ynndNw7vR^!CkH?58otXauI!b+b^pUjHMvM|4*P569dF)R?d5MY== zQf(xgvs+D4LT*G_bi`UX)J0et-OSIzX_}X^^0;V7ZsCe|450T8G}NYxCq$-fy`tB| zcpF-SP9ub70dBzs$GUBn@`%r%kK;wp8V!I1Rz|)f8|2h38^bD@-p_1DebOqZX;?91k6cXm~l zFWuh*LWY~ip?Yq3zpO2n}83cDjiFc=YsA@#9XnA08m3}@_yyntHG~Vxq3s*nMR*0qR zNXZk|8M&v&koO47=9UPDWRY#Nk*}$is_2-zF-lS8Ql*t;2Rn7Son3oL%RO+Wno2@E z?+H;GJ?tDPx>1D1)_UqM_4~uq^3XRD_C_YqL~y$Oa%zI` z|X^U0UB6P!>OP0S9 zHOYjZ>Sg(^%r!5f<&8p*FxUwu29iNhF2o=6e4`8wIquzyZRtW2QSf`YcU0G2Erxnr zFAlBeZ#RI_nXJ-C+YWctZxq$mK1C_Hp@gZ?rdl>Hh8(RfP@4X~w&)4L$dYMi;0gyxt6=YZqkaMAV8-)t!_ zKVwTm4>`q6hH+l!?ZCumMa~4Un-cP}xzYjG$j1BE0rXK@BGgyr;Xq$jw{LKiL5~dm z^v+ww7?P=uOKC~^w9k57BMpOl(t8x}u1@Q`sd?nD^ucH0w9=iD8&c$gE4-WdJFy?R z9EI+mjlX&bASy*RT8&1vmuFk105yvG+`HD0*W~`tcENR9>*+6RXY)INLZNEYkC-Li ztDU`Fa(mMtM&YC!1Rdzp$8E0-6}k)Bh&2Yhp5poWY4ZKrOqmydvDlN&D{-CP4aPqY zS!l{+HP>j~D^qQa(ldAM&U}a1b=lR3X!=V`dMO}5am&%1A z__mI>pbUwy7gW_R<3H@nL+)C~81DGB^^`YcuLT2-P_FP6}W zl89cE>d`P&=6Q!aa&>E#_)7U?!Eo^(ENnSN!#D*`=#^+#7DVRglmvfYLKGU z9;8pKNhV^9wH0>AC0LwTmjf?wRvA1@=-Hga>Rg2kUOE3N^DvODIUhNN`GKaKhqbNr zo}}85d>fmDT(k)w6u&n1s+FY2W-5~dA+^q@igEYrp2>HG&amh4a;=mo<3X}V|Cr9 z$-|`s2;I-HNgJitr_PK|w@CH~_1o)~AA4-w>5*8JmK_oTL)_H{IC;b$hDrE_r!V!e zRaF6)}TZl@meus1X>f4h~rk7KfTn0yRLrOn?o|tp`7dnWz z3%LJMN8hE3BHQNy8xxwdr6x+p|3eh09&+bzC*z-gU~J&7B1&6GkX2Quj0qc?8(&uh z#Y$i8i)R65=P>h!M3Ixh4s0##Ys!2XCWD97pM*=r+I_%p?)Ao*<}fN-rzYy@DU}Va z1&%Er*tq+Tw%YOI4#6M_-g<1Q`aT)ll8DuyGA*POU7D7(ok*;SV0(PbY^)F&ER;K>3)Q zy)&wXyg=tC_N3w)_zZF{rxvv*Odwwtu)gVE@^Xfd_O>L5zCQJZ7|J{Qx?9kiGtPA4KR7f&=aRQsnFlS zVpRk7lOEn8_Q+B=P$8CgePb$G6_-Gp-PqVLqKkaJHyryc>kq?IDe(PAV}&C~96O=M zWDbI-58%);D@n>^A6?jfF9D{PYs3RuGv_j-u}-rB@E@`3kA%-uP6UM)c&CaLSbhXKm3=+RJ4YLpQ^70T*TP7fMh9OFBN!qr|jk9SRHuZbOW zLtPWm#eNppvUQ^BNx)N-%agGOpgZfMhN480eBNEbnwRaZfj5GTXnEpF@Aj@9%wi6> z%UEd_<8chgndEc%0oXNBV%<=1pS%kp?aap?dU>L2B~H9M0xSCrw9neXhx}&gEg`fn@`nYKxlc0~MT5mqH>eqL733mGV6vg|8A)lkjJYzLYtdrzrySz)W^Dr># z0lWVt(7&l99ijh4jP73>Syl)J4g-f!R9n_J5b|+w`*cQ@)%`RsAlo-FnaJ&TfgdIR zFkDy)!2QFI`0&PJ!OSPn7TT`G9+P4zyEJooNCP1vb! zaoFyu8YD$;)W8MrNRDv`@SsmeC7|*PYgt$#w+ob-0@U<+PZwnUjJs|oZO^oIynT^( zmVfb;wxp$K>{%geYwvJJhe$r>Apuj*_?JEjrQYYaI=T^4s=t}vnKWOPhA^9%JTrFH zyHYlpwv$k`=J8>h$LNTc7Y2 zietKKblvEN$rvSoFncTYp7UI6>m>wAJ;OsPOQK8obhNvWSM+w_bbT@lLew>Xt<3iY z)6D5r&%Jo$Iogrt9>tu-$~#`?o|{_gxiTriZFDC`73qA|1{+=kga%&RQ`+hB(cA+C zo=U|;vZvXk<-z!hd#i0Kv3j-tc2#uLf_97IjSE$C{7!!DkG5y0u=%CT0@oh%S(v=4 z9Itq581NC@le;`6|GjU)dCp%X&i8PU_~ZN&40A7(>($%T_#anxdl1xHGq{u8*hUKt zEi``f2!^P!C@-C`qWfkgQsP?6)NB1aS;NsjzX!1I*cTG_71IuW(Z;?$s#g;>`epJO zZXQOC%uy}e3|{qKO2OJW5i}y;v?zgr)^hCGPZnwwiN}6yJXvpLK3hShzC9yz$8Ba= zciq0ytzTCpX1U8i2R874wW%T>bt%jys=4M?Mxbt#^<>2hQe$#56hwXLg^wOSD5>-N{}=K-SQpSqP>E0ilB40r*k3ok&4<0h_VpAI8o z=sNFkD}%`Gs_+ufJv|M27;i@(TN3QO&2^4hTG(Gvzro3pm}#|Sc6RL7*!bvJ%dXHX zS$>!4P=-Th;+VD`B4An^O6DR>T_+iw)8}}Rmr*zm3<+7SZk5F<*R;ecBsd#1Xs=~_ z>iyI)JC*t61s{jx$+wZYOqXv)+b6^$*-dxc*g9=rKCUY%tBF)%lQ3dy&uz+JsNYmQ zSN1>8+u)1T6XkBIvsyd(sY$n=e7%|J%a#T3;Kx+U{xC$}BFS$ElK6*XwdK%hxhTLw zASf$9Vpc@E;tjmMSsC`LvexOET{fzd%mtaryFEq7_l+pbMTpe5a<5k1!)9xgk+K$J zxSY`sF;vslI&0@$CjvUw+nQvdLd81L-KDG@Lxo?a1aPPN;n-^J*HX|c4ALgO8 zcstZzT*Mid8ih?UlSUdLf>SPZ9*;NW$Wv?F_4IT7Qk}=0w3rp9*LyU(pKge~SCM!n z+0bg6b-RBLr-hwbTTW2T+UPI=e}KSsM?Fv{x$SN6aETumYd9S;P{-nu^ya6nVse%0 z3*jC!SS8N?h&$7st~y+X98KgZAnCV^1*5B{TOqgypGr+phh?Fm#0()^S4nEN`g$o`O!&UOLdY1Y4JS| z=T!K{gW7)-yk)-d@!?Kk0-kksa^}RadjVB0ixM@9jo9`(w{LNo*+oU5nAU$Pe0TSR z@Ac0|qDKa&`GOfJu-V0z)LTF-I&}B7w-nplO2F3)4;LrTpsQ?np_elZRpk>4H>%@4 zCd9Q<57R=V>1Qbi4T4vRPBAt5pGH>(__}p;2Zb#PM~t*4%gAh*EQgLFdDbQD!V`AI z?iu*>1z!@QOn0>{|6!Q&p;xUxg_Q}QBM|Hdr++P{lxO%8!4nT^WG($AB@@A2%Szni-MZHY`^fbkrU_2-`v9E zDy>S*&cT#H9eYDlV6VZ{3DU=tLvsB>Oe$0?hgbRT57-VIsNO}vOJ&zhm340_PWh}a zd)dJ1(P4_g=Sc1a#I5@jf53vv4&C@1ZdMfP5xNu8J#kg$J+VUh!ArixC;WNm(DRH0uVlH)K0kMa4`m(^3mGI1H{Xqap1D^gUxo~uR^_>$XE4P!(gEKS z^f)*}?=eFjN4nP@Uu;Tdb4@=&(n?s-;2q>~D0CCBmQ`MpmwG>GbpD&j>3Z`e#r!-E zynb!lLu<`wUm!3v^1jqf5X0{b9&HaBa{>UA;DH8qTmLoH9W}TV~SA z>KK!3Do9@wN9eeFP$3~Fuum5F?x8w1jA=#TFWKil$cuSn71#A9c$jHD9=I*&)px{BXQtmee zIfKrnf|d&AqsHsMZ$d(kVeq?=bs{=&mgG~XGkzD9bya6G_IHMWE~$4wU=aqb!HZBH z4i;JI_4slb2`@pdsiweW9-jJiCg_<~P*d)h;^(Z1@tl(bxB6ZM9KON1$-%XcuE%k> zj=87iSxihnunvk}ULNq|didSZ^zn%ejR&4-mFkSMt5?;Y&xa>6TI#xGtW-O7ELvRC zu7UTgsqer*_mzS<&~!BugoHPd-S{>|R?n(mblE z;B00b&Y#Nkv80G@-#6PUXhBu!JEzOHi?09BsR~&zK;VIJ5BHScfO%jwR_s4?qgEb0 zEhQ35x%NR+RlkbE_lj{w=DbN;AXA|;yN~`i|3sdqvZ3g=SkEFyETG$$R`4eeaC9aR z?p!$W2Dg$A+2`2l{HQv!3^k%%60kIUUjd3W+erYo$YI`qe)_jQfd~h&5f6x1?m2_> z(D7593~@Ni11}T7^_}q?`F7MOTvlcxa?O0gm8UcY!6l&(`op+K zFv`W=SSpNI@Z8|=7TzPgdp*HU15J)P0VuAYq$DY^RD z^xdWNvf52Zj9eG~XUu223w z>&@KP88lY#P|8JA>uLSj@YUVf4L-yZVoy#k^+egAJZ0@U?r2$BdJf#n<$tjD=J8O5 zf7`H9NtBdbOl8g5hOECF z^_<e4gk2Lw#mkuI0SW^Zahd_c-?F9$S$bB4@|H`91D7RxGQ;ElS1H z2q?OBb^F@zUd;zq^1n4 zZ~uDbYMkP-ny(QZwtD@QUn!D!B)+KgbVb2>--y%=)Wdja0et0xdEJ&$fZWXJHC*OO zIOaSnfQ{AYwjyaYLK9H@MH>2-+N2HomVWmch?)jELZmK)^V=VCQfw3+|4l06*Gv_kBxp(!M2Z0q?I(35W@FmLwy9&!qY?1c?~i>tl-(8l$>W>Q-Q zYj90I@k;z^sS;3p?0J+WK(}`%|0)MbmWKf$R+nO%9c>B2gGB}Fb4|M|9Zbe$*u+CM zjf%V-J8Q5+0g`mx6eKF^Y$e@((t?K!Mc3a;l$(f+jv`#ImOOY_?(Vanp~)&RS>M%z zZxOj%wnMMnPISi9*7?IaYl@QaNeG;MQcRd7UE-<<-y7g&r+t?w}}@^P1qMr10>iaGD-;) zV=&Q#zj<@-o)`mBeEd{(Kf*d(&$Ug$3I4*OwH`w~_tA(2-+*u0vZH~X0xwPL*%Hf~ zX>!*R6=&F|V5MmAhqNdOi7fM}Jb`Dmi(HE~=NEa(e&{9qTwd;W7O? z_;Y0=T@DXe;{oGz8aTI|#58h59q z=UVK|sb@CN(My;X-o1|WON|l(nx}7d6~RT4<{8c(f&*k@B(5BIc)6gx5AU9Gr8GX& zBdMrh5;szrV7WO{W|d(GS9bR9dHl>jId?;`A5;oDvHhKFtpfNbBtkPeI9a8P9h zOxAb^wjcQN1$Qr4f#cj_W_#H${!tw3yXmID^9kxz^~b=0E=r$hnGn(A{&3Sc8`Wee ztL6BeS0yZXd2{Tv?`TO?hSt#qZ9Uod+BHDQ^EENpX!Opph2KMATS=y(_ypZ(8 zucG6831hiYvzOs{nZLb+vSrK#_BfE<@)kAS_uZvl2H??V$j&)^-$k z5F~y=;7%Dm<$c6!xgWVCX2^yz^Yt-=j*pG(%dg_2&EFsG*&8>l>@^Rmggi#LPN8%j81K45Yz^XjqqRF~Y;^C-Bo|X&Tf5<7X5D%=J<7M=76)uv z_mBR5Rea<-uM7 zhAJ?~8lq}CwZH=ED!8LEHj>GePR#_c7Vau~W?TIj| zHRcGC04XIju84Ae64hHFJj4*xR|1518ljZYRJrCj;dSC1x-0qmxG(Y-E%DeXpE%un zSN&`11vvY>3OgoYbVZS;d8TlUxb!H@hnp4U_(j#OFQ<&QZjzWWo7HFH0^&x5bN6DM;3UP>?z$5J7N62u3Qs_nms1%*(A&TzW8Gn& z|9{-O|Jna1%1~eY-sk>j-@$oEB8GYqyRDabjnfLpvlT0_1i;)3-u}PoO-;~K0Omgp zurud=r=~HZ|0CDS^$#5!*AeCgreuJE8lNz94gF&3$#ymGb z!V-t-dp4!m*mCFye$(?OsGJ3u4Fx(&w%Cm2G-MT&+t6YcJURy)^h3lCLD(%^A;BsowR2z z8i`xUPkSseg4*k3~&h$c)MTJv9%*(2Qe7m0!x1#kHk~ zFwDIFaHZ>A@r2yD5z16>urv?aHACR+%*1)l3f*sLBjx?Kahe&1MWtbX%?}>*7IC59`Xv+v9J>C@>e~4=CUZiDp5Bg0mEHPF;~li&3>u zC>2}C;$t2`$k2^HwfolkqGir&tL}YxZcBreIoaOV)x2aU5~^u$S9H3tA7vk1F+5Ie zyUJWfXbJmMt{6dnkv`Yn_knsjG`$TB?$c0L069?1D@bM9Qr#4mXm3Y##-S(0IP%^) zL4_^$L`qRCI|I_KIXAi56i0F4Y_!llIeRl{*ILXZObknaC6<9z9j0%QJ)5T8f&4Rp zJM`LdqjQ_Wkr7M;Lakq>X+cLKWoqa7HvHL6PV&u&BS%i^ooN=HwbUyA^0{ftr%*V_ zap^-MZ5;%{r*aIhM!8)~w@20G+LR4e)yBk3xaYdj^4n3=%h(s=2Y!C6e_=<-cJ7vF zLA};kMa3cbz92MdvP6#(xLn~_oolx1(^izpxG=j0DTy!|oa#TcWu9gJ;kK>K4s0Tn zgoRPf!y&OQqL_9Uz6vzca$H9FqYV45xa>jY4HzHtm3GV8!cF~2c_)?HLKLZc(b8`_ zH1)-{u2vrdbD`Nq_O_eLaFo2poX-g*-&gZhA8&r3glJ_a;nlCL8myR=)KQlLngil=j=!&#kHhvytq z3fm@Bub-UFQ-S6nw!^W`i)sD^t7p+oPr_oD0h=atflaoWQ(z?}uN{ zGW)}&fe3FiKio2)WI6%Tv^mn~-Ys!G3l(B{M%i0XQG9e}4b9nm05!QvXL7z4gLFKD zZ36nA=|?Z2S{G;lfb&@v%Fwf4utj=KDzMN>VZaEP8ZVFjWBKgBzm(5r&@3qj<|)=i z_GdgPL+1d7I@^dlN^8p_I$a)x?kmUhQkarsEU3pwB|c(BwpGE2b62(mniF06Y6W`yG@zx(H>(8~^)P zFk+-IBba4Wzs8BM0Wq&9Kph@{E-@k|Hddj)SkNCTP|g5MGMRZEMXnh_ZA+m4{VROM zKE*#=&k0$mo-Z&uKVUYyh4SoQ1}u$;kT^z;}opOV9 zI&dUuJvoyw9^9M%B0Dt3$}_OT^l_;dZM%@6w;8*Orxy`FV~xtLmXA@f@ia%qxJYts40gLiY{AaE!A5{VOcx zVoX0r=5P0{K^}?(F7^A_fA3iT@81V>Qezggwvfi*hH`E|hS!<+#c7mKEf6$a+_n^dF)cN zes9d5E6)G%i8jxkTFgi+3E-@N|Ho0IwnwveOch?TA5@)ma(YOmOv@WNXbHZNv6Ze* zgcZKB_s|trafbQW!ta!?GJjk-AnfX;b9Oupm%Ap))C5e;P&jt@>Ev#7%sONkP}6Bd zsA(S}s@hwfz96Y1U1ny1V6q*Vy8$Rn|kgHuFBhJ8F3d z`UJ=zwHAGR9HbrSq!6=cfAwUExn7wb&L)mSakx4?eS{!K&E0fl*Wvbi#ll^wRc(y6 z*B%#Z)hgVjIGOv@w``2$p?)V`F$wEvLufvEJ5*a3#||G`yw9ljfy+>v0rPNfJN95m z8PnS@8Y+zwrhnOxNmyR(>82fsd>|?tnp$;8Jli^n>&1w#@ znR^#0JES6N_H#oSu3PP@Ii2*K*7?L_x4b)m-rKm z9lL%FL{mLi)9UfY^pS5VM*eiUtwqKJ4VsCf2lfDD+?PdzqjdKvfKzbH)?}YK|MM*T zbKrFtDRp(lcm9Hy!F6qMF^@*p`~W{S170=&5by!_W*Pc7uOb_2%dN$XA61@NSL# z!7$lE_ca{Bx20>pdJKbsYJ?u$z&xEeXdvJaqBhh6JmA(B>-asLYlhi#I4K-KUA-ly z)6Qu)Nzs<;;r3Phy(5a)qx1?OrCCrts9wP0VQRCk^q*}?GWYuo50l!=jhLzQFV)&3 z_UMNu<<_1(VT5J2@Q0>0SevhleJL{~d3&_Rbrh!!W2~~Res4{pmu#8t$X)|Tm6ZrW zlipM0y@zTctCeo?{bBJ@Xrs983;(R=Jbg8)et?pYX?9Z+M`H=lUv~$VT=3l!2~}#a z8pAH&ViuxhMO9?nhb7uDN~O>&2wgsR$rNDO!|02ipF{G=kd`q8(~AD_V-FCj5$8^y zz2kPz|4fj+e+)Bbxg8cE&-8nYRM_rwus8xxrh7F@*0i5TiZivgsyim}R*E^f<+eJX zFr!}GYQ|RCY^%m$ygNGP(lMk6%aVv!XT^j?|KSp!sp4=UyC6dMP;l5Y#}puj{Jb&@ znLM@s^vveMRI^rbGyKr_`EHrI7GY~!Qx!|DUFq!R4>*i)YX|_+OIydMSEk`-5X+FL z;&tyYy|0QY<~bKY2DJ-w`JWM9|BCqf*UvC{fB?IS_p=h>v{(SO7o#PFRtQc}YjS$w zr-giv%+iRa*l)D>T88$I`==5D2A?zzw%T1+WLf@)3EDf`I+;h#(;0kd$8yGBTU1hB zeE}uaf`%G#{4_Up6l50SXa8`?4ZZ@0|3=t>jOj~(W;APOJ?PB;vIW^Y!}k$Il?7hp zq9$J*^9>`<5NR`pY7F8iIiBy+jHHepUu_w7rb4MoA7VLtRWwJkaQ07*M6qikClYM> z9f7DX+GzK!cYnAXiuET(dj=WGt7r}+o94#>$;&8UvAO>qb{*BcZUFq2pAA%SJ6!6h zZ6)-E@u?-;x)r1MF>z(tbK$q}_C)|%g~rDI;X1tn?(r|P&h!^Q7Sw)ni_M4G51@!U zw*b-h7yKZ9dns@ZUjE@4LI?zJK?;QIr{}=~E+JgNQO9SS5hy|=fMv=JoI*8*;M6VZ z{GySPhjJk_a(Fw(qL-69qyB;U6ICd709<*|PqPcVZ;8m3hm{ZWai&M8 zJR1t{xRXNiQ`WR7b(8QpR&dUIY{1d}sS@)N9!dUBrq^qvehBTzdOIbqz0!3b=2(>H z()IBEj95?p)gYe$$w@+M{zRNa=eI?fB!uqaDb3$r%zhwBK83W_~4^r*!aX>}Yt&+)>p6(RVr&?!iN@;Huj zF68HvXLhl75VEvGg<9JBE=&27E21tTJ%6hzvPl<@LRH*;qR?SoA7U~lte5_AnTAy@ zS^wcuI7Y@ZSYUTBHiqTdZo`>EsE%(TejNNU;0E;)7Hkn!f{6R;s5k2@DAj5Z`FFkl zfLs)^c2Q?uK>oDI>3~KfbfJvk6X9Xev8Tok0{M8WYb>Zs=DC}yR}8H@tvHh1Z|Mcs z6)zF51B|Z#k#F;fkD4hnK_AX0X{|sX zpGkcP{hqEDI@JY>VH{5 zn)Ud@wH#ysW7z2P=dxEJP_`WA2H-!SMvwj_;}++fKwrf)r9GRhS2wXiRXz?A$nQ{IJyUAMvIJw%9AiA_YWnPc zw~XnL*OHd|c625Q=tt*-{V2?Lx~p?%D=pdB2YxTg)cqyd+Bo3)*)hkF3sn8W-_yM# zjw253vZdede}2y+kGn_jp=C`?j%WXFpK+u=9IU#;hbRm*W>qAQ^oB>kQ?sOt++GAJ zd|2q?ROl$(K5^^hg5cmfjBv5N6@_Nxwpd)8)Hj&)(P}132*+{qYmd@smM+#lLgHqDGcg9EjtGL4ad1)D@C(BX?60KUV4VND&v^qSRqW4iL z1VWXEBtaaPp`JZ2P}DMxq{EoGJmq3PTZHPFeEjhm>T8tbf(Zq7iuJIVCbt#mThSeE z-q1fa@JZQM9+o0#Bz&g-nE!^hLB%NYn;lY?+WumyR>F2YONtjQsSp1lF2xV|Rc ztKi=6^vPqpO7)bwWp7Q(96D{Ua8A#@8zqc)Qx{IqF*C3AxZ4x#@NB!cFn0Y+gLIwCgwyja zvdx(L2`5&Nm!)~HBGmTN>~r7^Xdp#Gk{bbyV@bU&^>$bDefp(#{K4axuQ0#RV?TY> z4h<;=sa`y`FIf&xI>ci zqe_BSZ8onghfsR5z4G{~R_bQRjf_5T62`UGBPM;HtP?3CI($PHhp|fC~Q|TO;C+)&^tht94fx7 zCpcvl@50x|k!I-*PSy+-N;@d{iF!XQl8fPzG1~9$cRIghwkK7?npy6r)q*EwBXvsY z80JH|XG^;Gn0y9yK3zKduG+1W7So-d=Uge?sD-J>bTCI@KIjd;P3gqeQhWRre-6T(vYnB|>R<@FofCM*2z*E;WJpZ~He{6!c$ zW-!RfneR$7g_C6PsdAfBzoBwIj&_=>q1I4C>Bns5j)7XB+My%|2ew z)sHk5Fe&<6ZDV4020Yw46fU2y(ra`lWOtvVvytFyW%NnWeG7ctZ{1E@PkLH*)knV% zK`nz9t(fal+$`_59SpF)Ij=ETWR_;iO0)I!vFJdA(qcEe!OD2*NEbDvk4<5tqz}=m zzWSV*JffLy8})WeLU^N_@jgK`X+rMg7qKqf6+hd+{mm*IIb{hfl5vx{SAHu-zc`i(>& zbsg#Wwp7L{-9b;RRtPoR^+Eo1a>lb~Myh($%enVm4Z=PBgnx5DUg*i$5X;%kS2RyF zOM#|4#nT+GKB@m}K+lV;&JWM?{6J5g6${P0Vxgk0IBE3cvd1n`p6n1&v&J?JKifHG zuc*1SQQxe3UufA0-f6jcRilJ*c@`Cg!@M`&Gsxv5B zf}@M(vaJ`l3p8SXI&nWv{=svE5Im($m#ch)@;1lD!UPZzQ$5e5j}po=eE3?1aH5(( zT`;L{Skvi+Yh7igTe{*`!}Oy?d0&sN0T~og=Ha`5HmqlO%8CvrgPE1z;wYct?;qf& zR;PEyireZ;&+)tjMb0JJgs+-2k~rFjw<#YtYwb5I#x_cWYwdf(>x_=Sa)}K;%eh%u zMx^Mg`vj#X*cg`scTp}98H#HBk!J1&@?K5VZ}7h{G3DRGk`TRY__`+k9Evo*O`PEH z8h$R_q*;RM&U;f>DEZ#CsVndGaTUPh>MuT5rnp8{YOlY8--FWV-A-Caa;zG=-Lene z=ps~0!^BboGu+iRMxSU{k5?fMli_IL1-~E(&0mDwB?FVS6Tk9pE2z1Y0J3D}MTpM0 zl!eG0->Lgz*iEN%?N$QOd3H}`QqEQRr7a*YBz!l(K5M!D-c#rPgQ|4P_W2doVIYK`>b>J~{JzuvabEMD@)a|_TV<X3| zsC%=YRe|Mbh&WOiV(vwBfFt;&a?i1pKlFkKCj-wL=zFX`FbD=h%YD=k)Vu}>cEEa5 zTLdPXnzbzL45jpUWJhOd)mZnAQeQ zm#IL1#YEbVD^Uj?>|L+#I86ZrAQ7g39Rs-^adVKvLtnF^?THOgZRq~^K7(hcA!(VuOtAt@p4eja$*x=TlT=Due|(i=f1Y%4 zdjD)7atb*NqZ(oMu>!P+ZOKP8tEwClU7)q5bC$c2*O0v^ss%=x(=&dwI>B6A&BxB7 zr1Kp`W-*4@vw0T;tB>@~`M6fn?ge-iN+_*dy>m$V&2d~UwVk3%t=jxW51y3!>Dj;G z6uaNR`~?-WVANdx(s&O(v)aA|CSI>ncmy9uMW6htAf|Hp?0ri!-fmd8bNdWJja#$( zG-NAe;9YiuM8}T|kgB(}?OTnjPmNy7&i&OP@Zv*Rwd9=ylF`?P+Kt#j>@e1{AHhQ< z8hUAM_r^_c{D7}1SobI7()zE)2#@{YI()*Pu8keQMsY;@DLA3B33m^3;gkbQI|E?D zoD-8rif@Ei;6ukJ{Hqdvwc77~maEy9nPL8;{U?lSX`wS@MNDoEa9~Zi*H#%dy%f@D zF=h}JdD>kuPo%+=D3)5`BsU;I2-m2Z8?`ni5QTUnVF!=(kbO&OZP8$0Y2Q3axpB}X z-f++$Dvkev6<5+~J@sj2xlI{n7m?eWrRqMMxsqd2hmU~W zr~LE|Lt&WxfNS5`jQsqlJ5?2tzY|{_>iohhyW_`w-RlGC&u)%)*<_0<-0^Tgm{Vu6 zr})qZM(9s3SJQTX2u+?(oVZMQiG`fGEE$$yEE}9dK+1&#H~o&v#m?3Gn?GOrM=5DOXOAx)g2-xZL` zu?4NiiPr89M`WdMW?Xo#*TSk8NAR+ra&~Zf4UU8D;PEhhrAhj5eP19%(xt;qulF6g z`IKq4(|1DZ(`Di}XY)7N`@YL?C1ner$%xcRk;O-SBrYz8D`SkB!tL-K$*r~} zG!6io156R0Gu*WLC!#WgX{P*byyy08!1lW(;>iaAk7HPF>^B@9KIXa0?3*SIJ1w4CJUHVVM%=49-lqI@bX2kGSDSY5i7sCr`5FwdXVaa!)rcPU>0yVooV7QlbJvG{%~=}gawy9T(7Ua!EP<16`?Kqx;#9mHUlOkgjzQh)A#>XNB4E#WnAlcuEKdw?Q;>D&9aCAY1D z6`JZGpq$nixG|>COms;TXht1DNJU(mv`e}b=XH9l9eJy?w)#0*{YR4E+3?ry*)P^! zme)kg3Xl5};7#eDSEiTy7Rwet1ug{G#JkX*;jVq2&snHK`ERK}1rbnxsXF7LxjxnD zD6xIxZI_o%rNA;~T&wSEPlpjsku$IoSc=r-=OC+tA!<`!fm*oDT&;e=A+;KJgL%Ui zg4)gOi_?l|?*q1v^^R~nP>uR}e4s))?72y!PCP=G=8RS~LWEqr$B(fy)HUuNXG&#$ zU)_VR5|?Z#c+_!cRddQmd1@w^djO#hWE24#pB-i-z&PXl&*{F6mB?}F+JPnFk2AZM zuu>erJnZ_WGEc?Glxt(Ar108sUwmLy+;7iX|7RJo&Jtd^BSr}^f!ni678FJ0Et@>C zJay%>EcVB{Q*blyVFuaB0;@4m27IcLjti+>*@iqH%ZK)LzRm80A9BLoa9 zs0xonH-oEa@(r$J^Rva{Lt9(^72o|Y3L}3O@BBjY{h?i0a_YLL&1n?>7vs+Ld8Rq8 zqfloNn;0*$&~_S4jMqlwUK<|i&c%Mp@|;VJq@8I-D$;gyl0Si+W+2EyId(+XY3@^X z*({_!{zhp)`}f`cm@+ISoCGC)T5A)eD>#=7Wm8=mpwYfFRn?}4!k^1Te!5$kIxT7qnR1$?YS zswL@LUFI&O7bjox%0V>(Ds?y+RBj;O#q@+ria!n~RszSK<%_Or%T)bfBe-Zdkn*O^u=l@sd!5aYosait6vW&(}x*2?5 zu0?*<-X@_g`gnT<@`-&Tgz8vU)ehO^l*{n=#LLK#STD_B-(K7Xk^Dy!(s%r0EGR+D zn{3xkkb0uB#vFZQEInLVcJn>+Ho1)gd#5<(Yn@OdwWvsE`ZJtWB<|-Bbu{!~_wj<* z>Qgs{A3;-)-k{de=;3sEa9Q;uRW15hRbsZn%q)*|1-V@~CiP_ov&ZAcy|YL~Y&Ba3 zP4avu*o!X?OkMqP9_|k9E5J5R@ClaR4Kp}xB~r2hjYRtWLN(s=aTiM_FQ?TJ?S$X+ zutZ8*FV2!VSx^cnytP$q{_2@Ug_vatN2F{)KJZduO|CtxYYbh@3%iK9eoeVnPsasM zDgjb6)puu3{O*3&jSR-JM7b?0fi}+0E@};mmah!l;_v;Fwt)U=U;7MnjSA>KE$myd zm7GoEvL<{fZfc$HD*X&u@}ne6^i@9}b zb=6_t%)^eUvqX)WOdU|<9c9D@lWGJ?r?U>Miy1H7DpXTVDbmvEhu{e{QJ#OJQqt;#+TFFZ}7Tj<0EvZ zo(O(zcV8N32W+=GOGH|C#B1g$xi#+R$fG`!=S$B8F18#IXe(6!57&38ej6pQegstx zY`$anQ2QS)U1qnio&-~Q8F~>y!aT_q*HMe7ye2SW;yFTeHKC^Yuze}>%xXIJ2Po>0 z%0>B6!6fK@bb%Dqu>kOQrG8_{nqc4=b!odt?hO z=SZ#PDX09qz(;&y=(G#``-Y|9#|RsQu6KR5U)hZmMK#L#w7{q~e$N007t{;xp^E2)=HEwGVgib z5_o~lp=HCZx8Gls?#pGJL;i%7ANG1+MV>g&!^!M)8Gk?dJ@_(ot$zRjMhUsy|F=Ck zTd(z`^fU}iP|e%;UdaB~w(BhevuP}?G}b{HDt1NB>#w#&@;{ZE#01>0*6!WF6+bo-#5Az)a)1!|^A%{3>V=Rk`^0Mv1Kq-T> zH%JP5=2OnE$!@N@jKOFBaK+;#kzZx|%JZP8J_ln~DG)cprxCIZ&LH5NACKmBZer$l zTVS-2-J*~uVX!Xn2%!Tdj8k)((>mp)PS_g`b2VV0>4(3vnt0b53J?m%MJ!4*NE9NMJBncqW=--fxaBNe~cgU`;%DmLB z0z?z7F7748q}NzI#;h76G{76D9;Dg{Y@51c z*bn+pud#%fztzkq#i&km6;R<%O=h)5%z{XE{6QF_eR;HLwRz^?8OPj8XtF%7(b$jDVY17=y z3GuxbkNail`@`H(Ef--sp|j1C0HO}&`E<;t01+f&{Hf^8a(bphY`tgB!j|JeYOmv2 z!0l2w|GPai+nbhUr2!@!71-S23C<%Vh!Gossh@)7Pp*v%e;@UC_^8D6y`IzM>r_Mt zhN`DJwbD&H6C9KbI#<%tSwqX~6W_q!Paac$3|S-7c!0@0iCQ&;&<>`3MQ>B-Pr(4a zhHf9P6`HbnRs7))gH`D)OVL2G%72a zW5F?o0{Syv#NIz#7=hBD_{M#cLmy=-ASa5;hg>V3rhDtMkV1n<{Z~0`quUwYIR4bW-ZJ`CQ=sFsM%N)V-kR+N(FF zWLp-j#1(RoFaG^85TXUBMiJyOgblj@0VjhXc&7H~#F~U1?yAltm;89@{lLKsZ19OMt1-_wn$Zf0futdqqm z$}_j>6ZV)+)<@}^(1WKU8`c%NZukv0TE`&b_-IRo#jEcKOc! zUs9)F&3&|FDe@9+lsB^JddrlXZ5Qv}w)wt%ul975J)lTLY%Snso%>pMvEV+p}H`>st|5&PR1k(u$7R!p?as{()Jsqb8X z%_x6b_nGI*wRDE%F2n7oh>A@kZ!wJ&OWekZ|lCCAoibT zh+Ao_{;oi0-0o&w5@Q5l=C9UETI%Z%);A2eJKkGrHrkYNCQCMf6m*C)6K|8d1$n@d z2Jfpigc0G_H56oetg6xNSn^S~O678Z9DA}%N1q=&=46|`AcQp68h2zZR=mjZOY7I* z|2aKV9RS@I;&fQR2gYsb=#VQ6k7!SZ?yYtSiMDke2qdbuhRrP_|8OCDP!*M6r`vs+ zoy>B3Zz5P>*!Mf;hSm3P4tsgluuYKXz!}pJh~lW%_XX{hR*p3tKa(Q!@W2^{{Vx>t z0*ttiK-!}C%fI>J5`i(N=_fm!3s_zG)`;zg5f5{Cl}V_C@#ARcikMNo?{`v7Xwe-V z->}mK>f-mS9*?@me+!UPF>pd(U;LSZDT}=P*1I|5Zo~1uzQym`Z`G7Vo8D9)G$gLI zT=Uj49siI(P06V5d+6@M?5AI|z)33}SCB9m-Tdn9(Xr z_*0YNsfGOJN|Y9}Xu?1`BfuCO62X$CtA89B^R9JC?RGne{+{^2Kkjxtev`tk)#IUN zZ$22(Qzo0r9CmR!t5oc2r-ug4UNFrsR=H(V%HRlzrD57vXqQ%AFqrq{ex7(|cVTE! zUwao#{l$#}&af)~*}NL_>(g&Dh&o2BJYCZ@jSm$S(4+OoPIuE5I2YDrV3u7I@iTbS zz#Gfzh~4~_53?PPqgi0r#=_p9_V{=VB4vr?7vAI~W=PNA?G295EiH^2YaQ+oOE1^- zSRy7t_*hh`$jXiUh#~zR4sY}BG(a*lpFUm~Qc1K#-%CqNJHWU1 zc7nAmIZPrT-CJCBG1Ji5(K^uT^{X*LE$l@Op} z*9d61yI7GT)=4A(T!&ExTB$bd$5!u#F@Y`NKOChX*KXT%@$^hVKMvzz0I&HCp`I@T z?etUakWDTtwxSvPDprL367LG##c7ui8Qa6Ft}#pw4S2~wc)*Di=Ys{X7D`@U(yiemL>K4}5{u1`y-T1fM%9GQ7 z{m=(M+vGR(ut`Nt41tu}RP9zfI7QndTQ+CD%IoSN=1)yL$Q$sg2n`S z|3;m5*4YV);ABti1TR7>$djhtb_u}vYH;EDhOJ2>7xq@x8hUI$ZJv4F^F~beZPXE_ zF=dMBxdOeyrG0n>N@90X6jaHB*y39^`U{^b>*PYP>oF)U9MX|xH3JEZDBy- z33$)DW#|G^%Gl`I;1@V?gjU~1zLay(GUd`Xf%di7+>fu?Z}jZ3H>C*;S5I+6jTSJa z(58lP@;Rz>HkI%-EcXdw>Or6+WzW!}WoWFZmm{xW;zt#8(W51|O=aH>t_-iVaL&=t zO+H#6M@e>8Z$zrUnH5bp%FIW3JB=uPUleqD^r8-N;YfV8*KJ*UxK&!}zBEHrnY%L9@lff)i&6kDk^E5BMK=2`5-5}C_L+E8riQoPh&;MNmAd_2DN{L;uHbD}Vx zzV%Pkl*c9gzGpBxVu-@T4$S0Lfrfid$WA=p5=Z7{NE;-Ivm>^ZU>9wbannkGsFtHq z;{0h(wcvKt>H2%BRYz{uEhvVqu$>(^5@9?W3{F_qCE$h76}EsBRmoh}JC+39Ia@tC z$Y^N2#GM?j2rXNA?W1Mw6L&AecMoliOEhbbPCU=1H$8|96Q$?3NO(SCN#-t&Yg?(e zgvD3J`w0)#SIhVe#5~Rwlf8ZM>omc~P;!NRH*Gp;-MknDi$!Jl4VHBS7kTB^EE&4= zF364AMaDlt>?AHdalTM4CC#^|-ZiQ2yEmWGG}($T`Oz7dL_?wFaWaUjPyGMudi}Hd z(*N~w|G|gqBvaN3+daap3PVGvy8D=uIBsA7$TwpxU?f&#KOXs_`}s8z*bXuPVbxOrhaKBgqgkG_-+O3@kEDl(r~3iV{TWM#2=0=yr{rWG8qxMeuuG<{Ys!meLt#E%|JN> zC*`(Os~-pZwH0vV^$b2nVZig*;p0wEP~lZPiESYTe#4keZdyA9Iw|F3)qAfnkRB+o zy?506@LtzaZP#eMx2(diJn!dWUmSa)_55i$2BINVWCV_z(_ZytW@edbSH`?vX@3a+ z$qO;5>93=tm;j~2WQ96{XylSQd|5bciJrV zPw(+6)H*TpeiFJ1O2UkT5H1SDde3+!qvI&RMEhKwdq788hf3$1F&NxwXt!@q|4TA_ z6h*3n?2!k#;U?6M=G;X`{isdz60k%Yq zihjCoA4i9f3}R=j+Lw>l+}QN%vFJ48XKH{V07b+=gfl+DhdUj@G^c`e1>yq9-r*R* z;MX0rLnBd`@J{afeQj|UZ)b+kn{E82+`eBMANqQND79{Hq>N|a{Rl~>Jj)aq0c{Pe z09~|&QJM5HIT-FL({_IG2@f>Vzt$sG10Ux@Q9G6-UO0P$sAW@7SbgVUm>|Qt_*^-1 zk<;bq`O9E$$Z@Ia)p9YD&5rKSXh2exSk33&36t&r6Rf29?H-r{;-5F-Va ziZ3x{y^RK$q{USTTS^LjylU z@iC=R(Ypk{$qWQyqumCIj*B^by!kdOES(uV<;O}AIE?zGi2~A)%|Y|j@@v_;TWkT{ z7a!RL`p|1FNHw^q1U3O{7b-q9R$o1kC3FjQrlZKC%0Lb@aBk&+G@sv`@_~jItShvs zQsAL|eD~j$a8>%RldDGmb;?zZiWjKxmk8IG%>ke7%0`y26m`ilqL_Gf1Auq@482r3SHpcQHEi7?e2qez#?M1s+>ZPan*6^vyAT2;OG(|96O-knJJiWf;{8x~(mxOvCQSKKN@r z{xb>d_MF4dhD>)CgR)oj7gjIB`7fkgVeT*HgeM9)23eJ#Lv1i77(0q2)Dwa=@p}T% zd~2rjB2WF z*F{lLEL0JY8kMG0K|q@1RRjctNUsu=CLkb6m5|r~=>h^ui%1g?A~p2T5fJHw4oN^d zB$N=+zBB#SKI6RWTWg%P_c~|nv41c|W^kCvlR4*e-`9PW9sP=v4mo$CR%T;3v)EL3 z3cDb&i4>p7sw~O0&GYS77jNHyU_R3!58&%^ZAA}oLL|!lF#vY)&F=2_ZCfbfxTNn; zq6I^te&{H}ToT$-`}0=iFps+gBQITC3uCb1LyzIf=2g@x6lnES0?~9ZTD2yAdBU%Oy#hZnI`wRx9o-8Z-4-hQ; zc{Ed-0sRe+1cIb*2rT|j#gxtj10N%<0bH?l@W({;O2#iR9$G{tSp~>H>(iek_w_kI zuW4zoTHlyhkIjA&-45_Cj3E>&+(<|;Y&z;$YRy+f$TQ}fLC(!VF4!>=c$1BUH}sVr z-H#G)9P@c~M;^<#Ur)X*`oTRJtCNTATdKQDa-t*ULJ#(Cuy?CO2+u_SPV?Q>IT)!n zzV9^utE%?(nn=n#`zx&Y*B?deU6=>7u2GD8pKy`dVcNASRMi8Y%Wtt2SY*tXuj45a z+wRT{XY9suh2rejaKM$IG5B{W&Hu`4zQb!#Ne*_Cd=P|rlzGel58 zElAOXZ`c95nQQ?94{VbSMpHLTUBN$uXai@SP)068n6H5=k{ZY+<}8epL2dQzxbN_isF5A^^W!8?N=3=3)P5X5lDtnR`5tpeISBz791bkpiu+#gBHn~Lx$7*~-e*VB-wQ-Q!^--ytx!>brA zq`#E%bXj!gIkXT#yu~#MZik-*vU?ygVk!}{P|I2+JK=m`>vMz)*`G9iH-gToH%Kfv z#*y3iA7(2A9Tz}3kWtWO`&hCwsj7o|&wKl7Fs3DsfHmUmb zcn%CiVHPsy1uhYL#zAcdU{_bpBVZk!6arj^7?{5>t8aY6ybIs8rmSlqY{?3rPk%I#1w9SgjZvz$R?>Bf2?`C-557wBx9!xa><;SltT znN}>v&vnN6p#@z#cHDbl+r`bI0pZDbPD-N-XF2OsAE_KykbmeLY32C4$$jB~&JDqB zzj_e9{~9|YZK1}}4=>6JQ__}ieIOnclCL3E_Ca)!p431~87NG9H*w!u_>|N0buk0s z7roX1aj^q)+#z98ApWsk%KK&Ul(IMN$m7-9pvV1;t6xlQmmfqC2vVEL^V zU`MHuk<<tI=LDtsH%~kMu5Nirpw#*$zjRZ?_M;dco0znb@NU&EC|A3tFDRH_Jvb zGevk__B^G~>s6I_JD$t<6HaL~8@O-fhwY7tFZ+o}5K64Fv^jZ`@-#wb?YdNO0RbTe zBi>$*Yc}8x%ytk_4|R?bjc<1vFOwc#K6Ht*3rb5eR%eLb2PitrT$rrsg6JHaLGX(M-sCY-eW zPG~E;zk$LLt;ztNTOI`E(NUVFAiC0kB+3#^`GyAiw~kk69foWtcmd}d!9xL&n@0A% zf;*lD$3S)ujTO5A8ke4X%7yyMsPTa*|y?S29Sh4cYlRIg(^`sWd-@fYuLn zWJ|T^ef|%YhjO5?H4{Tq?O%RAGsH{>td(Gw)8oSivhonP8ZMzL5!JSEjG_uh1-sis zwr6voBW~*@Y4HWO#{=^x>~DdMYnr;G6RNJqpXM#s96=4vztfS3+q1QvO^LOR<2};z zL!FsITaI~}Ev=e=bJKYN_N>rCAZxfC6VW|9PXthF0aP?xEBHq9szc6B5n;O7!<9E}j-0XbmAnhJzMU!NCkl=me!2(kvL zO=mCo#=X1f=zd{gVF4>hL@nrb{~16Whc{7-7cl4Ow?}o((Jd*^FnpM=-YDJf>zesv zp$`Iy)A^g#izBK(In)zBBq|smy?JPU1 zzUlSVHudI-9l_kV>vfhTEx?$UOEe0++PBUQyFvW$+W5^m@+h-kn5ET*Rm|dcc)<3#;6mm}6is~H9LUAwL%mZ4#cKv8gcP^pjmrZNzThZDalhwv zlM1FhC$D|`c=<*v^_adUV&fsO0Gx~hx~MbYK+R;Q_(``qKt<*}H47?3QJBa8kj~DW zw<)N)M=k;)Z`FG&(#0%3Wp5TqKYM*aDvM~`hT(ibM7_%)LSF|gk#r&p3gfB=>jS@3 zRP{YYkq53;^!{A9#>f8iFV^%GI?(g8E>ge*WHi8bE2zdbL2OBEaoTEg+~Z5lB&i}l z-><83!fFXV2T~lL0w3Hu!QT0bqumA&m7wA5$U zUmd2fa0uTgBDt9TSRrhC0_C#%Mt$m#5-|C3azcB2^GGRO131m)Kpcai&~h~ zSO+iEyCvq?*e!mP>$L6C|HB1h#WN!Iz*AG;c%g)NZEq6btV{%Vq0T?n6q^@YE058- zTIBP!!=&EYDnMq3KS0SKf`uDiNW4zx*EWz=i#;S!Rl!MDj>5nEaTC+&Dr+<89vQoTUhK3Ng#e9G!v-4*r6i zbDD?X<;}Vdh15nBT;i`3JUH?YtuFYBrBtRjYbHPR*{BnI-%4e#W~kBM2zy|amFiD0 z>$)0GAB5ZAy65GzSy-}KQr|$yAghwx%1LZ3_K*z^e7}WL20o!}8`LKdQw`lT&L34( zLqk{9L_shSko|EePxf< zXLqSOkr2EbKCCCx3%Jpp*IxyF7>hZZGv}gI|o;A?T8h=cNh|r=bvM ziNQPh_gM!ujb43tZ**1Stfo-6sA>LPm9OaS9#86X^{?|LRfbhnv6)F~ZI*@Xw>_t4 zVulcbTP9wUHOcFI`~yE4AdxR3BM>f%6zRy0((BzA-fyq~5-*g9d0Wk})3$rjMY}ty zGTBH&T+EzCQLAOvxu3R&IA2r<(-Aee%ZSBzBBza8Y5;33SK=}*HG_z`LvjQh#7ja@ zGr`u(CEo|xRP3gCpRCc6xc94_o#S1cN@P*MW_EX>bh{lA!Q@JAXG#Di+H)Kqqqz`w zMun7J^G2`kCEx4Yeu1JtJ&isme!MKfa^%vtU@Y@g1`&z}CuoP_?<3l#QY%YfrbBFz z+NRiM$uVj4n94qz%Zb^tyO+5u&s~oG(NXa}q_o`JO46#;kNY zvtyI-{Mv~H#lGi9Ob)vWRq{|{?Xx!XIQS~*V585s6s8SyF$FMbVt#fO9>NxU9WT%t zr~ez6YKZk*jHp2;HsVa1yb3o7NVECWPD+l z`#lFAI7IsVb^cs%Xt7Vl2m5I&{;?{1l~VqVRpyqAg^}Sefdu^&L4CtHs#NK}%paDQ6b4LY=B<{B@-({MV^*&HdSW9W|sL8Rr_&kHAn6m7_oOszroF%goCD zOO~OjOi#_`bl;-hd3B3eiLGHH1|1lMeCt2@UvWhL|N4FNao}5B&=aYTYy&G8Fhoais&BY2qbEQd2s^aJq7v6*qj%DniQ>J#vcAXt={Jm`mV~}68$@FJ zow6#^>E9S}Ow~#HA6i}NdaA`#w_ibUvRT7$fZ*v9+#yM715d9%;MR-)hGq)l^Iy8= z_ol8<8gXk+7S9Q;;TP^OhV)MM19K+eW z_sWxcu{2OeF*>^ci5N(Qr%B?T#(+gSb_8vh3!|lnUHl&A#D5IA%b3cFal9>$kNs|V zo7gvW582EGm|}Oi4HYUlgjOUdd+%>jh4hiv`*eYV!xMQPy|GGc)egv`+|<@93f32Q z_fkwBJPb@=R;i18{OeeyGYbv7gm{VY2y+|2&(%pGOFW&phF~7rNuDndk$Db}>0i!o zn6#FU#?sOmb)>R5-?Ct+d1Yg~MyXp(l7?B!RPRkz?$8J6bbZDr6ikcQO-`>ONyqr7 zjB9HY&YSi>_)&2x=l%kV@SAC%?nQu0W7@`jboN#K(|o4LEBex0=SQO*uGJv%z7cS- zU=v^}70J9r>BNoIvd-R`+PLB?F1BDGL?Egz8W%o}`sTPMz)xKw=REDquMe|J@;JXQ zw^k>?@B$!$#|R;c4Pl^T8fa{;8;<~5?N#wR-WyY@@2*|S`g!}otK{w%q)#wiAKfRX z1C#Pl+QWrekJfIQ8qL|mBfa{H^i8jzy?Yess<@cxioSfO&TFny`lIZzO7kgHWGh}lbLGh&@j7f z$t1g-lL`_5;0(F2n+!Fh7!ZQm){#IQDMc2ig#wa#=E}VS2;R4TtG%7Sdzy)hIg%nD zn|*?Xk95!uj;xRDRE&B7iQEg;87uMBbE21mM&+F`9*}Ny8SaWXy+g-a2)(Z9;8bQYf5+nXAkz# z3d~+~Xd$f)Z5}7wecb|>@|>yS%uA;PF;8cZB3r<`V2>t&b|;R(D%;G-QEl*8I}qOM@@v9(90D9{C5$HUU9WL9|7VBbIBB|6oA?xOP5P4Sr(@V^E&_rqP8zvejVDG<{0JJ@Qe2nVfGgj7BBqham}agn1eD}_y=J}I<2 z>&&yHf}*gz**>YXuu#=rJ0}iOc#zvMLBN83^G29gSHE)lM3FRyT>bv&{?5b zN`(wf0v0Xi_x?nu{b%Ikx3XIR_9h9UzZ1IzkYm3iRxGet89<8N=pkbD&Ym*z;0FvK z3IK@#K=KmcpE4rd*qE;6<=~=hEEjn?S}VQW2~2zPqlXwjDytkZif2F4g=y=fCE-(lrT5A`IBE}C!WD}Y6eliKdT1IePANq^ z{PFGu5^Tb+&bqzI%}5r;m%}H~Q6@lg#UO{j5)C z0Dt@YI1Ixd`ddcm?*~R;2j&Sxk0=q#cmtrUgL(l0y)brwgsO!@(bzhHr$+SJ`fj5Q zX&K;0U)vK=BtBfhTtM{$T-W7|C<01=IS~lIG2%}Xtm1b#`NbU*tx*vC)KSi5yJf#i zL+lIYX7d%8`3RFQ*niFmq=t9QFD(YmIbuHV9c%DEHqS!0AkheW>tQ-Y#inTcrok($ zC*j5c8}GC(4@d*)h-Pi7O6rcfox?`seq+9&PE%#m*<3u9xthjYiqWg9BUt(y!a?`y zI5o_|GRvnr^t1U%ZhGqaBzK@$He^?-*&Q1&jXLZ3%*pAFOS&%Q@&?PsaiOh-0KAE* zabFL6@*Tu+(U+nxivzY+&*dC*)_Hv81bDr#xy}0{(~hHu+Q&y8gr7Ru^@V? zSHateP4)Gul7(xKgZk{kSy#+f6@&YaV^+`~-=qK54;+{Ww2lZ#%mKICYv-xSR%Qi4 zXw~!7=7(BA)bKwIsC#e|bO)NyO1gjVH?-K*@+a4IE6x;w$wo9qPCr?$&JeeYj99%> zVPlU5K=1rwPNh8ZGoQ-R0A@r-Vh}l$Yea=@nyg{@h^yW#=w}Bfgby2RU=@EK(eXZf zR;*)fc4y4D1rBlmMJwBx_*PHeH7cY8Zys8s+qX~a7Emx9G5+GP%_j)|qoxKp=xUd9 ze4)l@k+-8|^Pv=i!|fyWT9#*=*X1tzpl6Uho!cZ#1dfF5&H}Zsusd6heW`rN7z^P; zbYXPC3ss2p&rGpt`pDEIh)Y;jEm|7X&hV} zH`5*Es#t6u{^I-FM@ySZJaOtHaY??P^z&w514EQ(^MV~}MZsDd&u_o_`ypkA=|(a} zBEz8y>6M3oq+jPO?5}9>=3|wt7uUN#DzN&TPjK8vc`-5(3x!aAuxl8!r@0;|_a%>* zG)-zazVJcKyy0r4K*+KN`LH*A({J&Oy95;dEDh%ixB{1aeX{Y*xvo|?^$yee1O{BR z*MgJl3^R_c@T5GsVRCNW+y^N;wbiefm2e3oXx=|Rw#zQcemm5>INuzMGtp~6_^rW$ zaEcW5;qmGAjr!D>F!F4sjGDezPqRFtwN*@=r1W@CUO`KJBD1l=1!>gF_zqr(&{3hN z2B|JbaFJ8fkNC=0+}9*!+VnP`X2k1U2yX9A42H`e-O(xt%d3B=uk{<_xRAgB6YT=_ zM_YBosfbMJZH+SPq_ugy<%xaenY7nRmigw@9)=c9`-Fa0N&$aFZ@Gg&I7#|xgNCYzByD!J;WhU?NW3 z*WA@lo!wHU^3Z@aKCNv?n6BMww343OY-PQzT*uHr4Iy75h=$NeFzaUNmgLA2`h=zH zZ&3v2J{1%NG6Hi^WDm~&G%vD}zcO-=zgXw|oGg?r@z51iLfjIw4Hwn;^5KfXxKD52 z4|*8m17hJ5l21pHf;JhG^FORYmiMM98DIJ?WA*0PVKhyE$tg++_46Zg;XqNUC&}yl zp?m8U1`;1c8Ontqdj^Q)+X*@XA8-OC;qofcwgSQ*kDqxpVY3K?h3_&?vwdccz(ocd zm2zg~m&sv6wJ9Ii*%jD~=#X~s+1kEN5Kl!WHQqu_!Qo8`bk6d?U466wkCkucKXfgz z_M13luY9NUDK+etZFG65mDaPNm=sO3Inw~AcyW zed)YNhyx;)DT;AS%@D2Xbn^h;xNL#&nv$>r{w@vm;K)5yY6G+t zuAu(~{~~+j@Z#YQW&~BH%S*ER%JwF}ocQqvdCx$Ow*C!m|2y*jf6QwrP$?4cyH7e3 z2Z3rY&MI;=EE@Bd>;j0Q5bNZPYo#3`p2JdNADM57QP|`s}oKbN_M}K+q40; zI0x^CBF{xQ8_HX$rzzU*Cmv;)+FS8PPaO=d=YP~q-I<}aGf$tP*?C|px7)P(WW9d4 zko23*OnZzMPX^57sr=NEd4nB|roSSo;=3L~l&tk{)%pB2n@Ig#Ew63-4R(91tkDo} zVnnkXMMeEQc=ieOU~vcWo{-*0BqsuW-+au~9r@A*HSyG&J7pTbn=55{E~Y({z0b9j3Uq~0d-;{b8x0(F$~A_6h(mt}!K2aNFG{r{NLFX7zDxW0W5lD4P z^OfxEY!4TN#&n4*+q!RMhVtuNnOWx&XnO{Z9-uX=L zRaTd34j%@*A~!=jn^nie(QjxVU363WWgmqKyIi~T3Z1n67Lm+j>rNRoX?V|+?*;?+ zV@p9io&c@A+jpfs3@M(%Mw^WS-Rv?X8y$Xg=QiaNdAhYW-Y~@&Wxc!6TO=aG!4g?IY zU%M+OZ~xJ*?eymKu7R7wyFv)>ss2e>=3Sbd?NtIYKX`ky4!|*z2AFM$h?OF*4i(hY zF(+bLoazk&eE=O0rcL@ZclyzO+zFC@UAM5?W6ss&j}XLooPowd&us1;%DI-0d2i=t zjfxU~@wxM=WE3hVxk3fgk>+^@3^uI!LbF!h`r_7rZ z-P_gmGoFrDc-q_=w-eZY*Q>={Sg!TgfTLTJd7}*(_^2e`e8hwyV$U*#WOvtD&Q-nV zz9UU;*gXEMkHW9+AMQz5@ zZtVp!AoU+C^&@{guWU}u{I3ws{{C0q1tm7^%w?~Ccu zcHS(p$1h+$1qinD3F~Gq9tYc7yT`)#a$&cLOI--vGnEPqEl69Pi2$*-y0z!H&YwCWjmHuC&vrbp=fPK z-<>*ovmFI*$5iVJ#3*+wiA}C{nvnpW<>f|k0^>o?g*QDF3hiU%uPD3FVSmK;gcgdA z_9Boqkni>@DB+ati2-bHaXp&OJF>uuc`kvSKL+QT>%ZEO0DhygDs*$j_?98vi@K9A z{QQ#DxnkFSAu}^d?jT*4YXO$xDl_&4pVQ}(wO39LUB8~57FRu8YJPr8%If2tUnc_3 z2BW?*rGXyZMn@7#7aO9ByQ`%8PJFhVTi|Va9m~1TP@DjMtATJYRhZ4Grf?D6=A^uT zCI-uz=t*Obid8uQRX8B%$hr-9yujEf*TCl)%dh>%nNP8G=?qzQi)%&tw)E@(K7nAT zd$6+c`g)xaWg?d2qiGuU;i;~)w)7W|Q%l@y26PVv(G&*hG~r&dC39vpD`jU`@RZ;U z+1ci!h*p84A z7^Ft%KRxHSe@t~>{}VvEm}F%6+coxqUm}isOEBp=Y$M{nKX2Y=VZ=6b#i*fyjnIsU z-Plm8f)Zoxo`{c#S<4=BML&bHb`lDsaTAYrr~%OJD`q90?rjf@#28+k1 z-2P5Ux4P1evyJxoma`-{C!aqA`ADy%0ImG-qeN7+jugelU~o&`e$K>{FYvFxaE^FP zTo|NuBKTGt)#Mq|*g~b3)0RD4Y=18wwZTTb)Vrni4zMJWhzNG@ehR7Nu+LhrO$mIPp;wdm*Iuh>%0QR8j!5@VA2EcrTwTUC*s@T^u=7`* z`{ka(>w(qrO!o{(i({ZzfQ){%(8$-(jFJ*7-Rjb#-o}c;#?6a)xjPwlD#xC(XFp0VW3_fCQR+`WM7CT1_rFJNLevD>#T4lOQ zuS*B*G$jO`S@0(*o8#FXI`q6vWhaNBd}${xY{U4gS<2hKwW zLwvQ0pg+PC5HZF--*7yB_^kJR^s3-Z@x4s+>#weRaMbJAEA3TQ81X}(4SQ`J+|A?} z8^c*ojYw(kuQU1fu^n7zJjJB4Bx3r*;p4fC0z2$n9{C>weyDu(H z15$OM0pTPyUQENbIhS;OE4Ey2v@le#EJb|=Ht3!1^_>6Xdv8`m>$3~;`7rfliUrLK zATWVjuygxFFkJwUCVI2s`m-m<_jU5GJkxQVkT+nP4*|w$=yo)7sL&Vi$j?^fR;i$- z?a+q^z(VYT+ZfUmwC!{v($$`x%)o4&h@&}DcIJ3$@Fw~;DZ6da3~vhP!YEwuu@1iV!Kw>4?wn#$B$@|$UXteE3A zAAF3GdtXIU4uCi`=@6149u?Ur>%LT$gFlo1s=!CDf+yAYD+>u)0Irw|4E;w!yka=7LI&!wX2F}K>8^+I z)Uh0oc5Y(@-0qRJKeMW7ck=QG-GZ3mq;rZSQ&#uY%WqZ5-mdpiUDXI~#+aae%8@rk zdTB~_h^YhA~T)OxiZ`(w$+a6vc$oPuC^RKB|q|6gB8|h zzX7*doAPvY;$@fVmwI@SS3{k}{{Usp$N)d`_97>d6iNycDk(1@T&us=c9!p<{T&`* z0&Jh&)XpS)?tjsQq4TvHfp}Pq(qY z27YsX#f!5P;Vv{iYjAF>AVAPiElD}=<^2} z4j>ld5u6H*7jYa0R-@IMCw$o{Yj!FcdqEitiJ9ZV4!)3&{ElLGT2Z$cMcN#nDcfWR z;x=7!us@)Heu-r6QNQpGsYTNe>2ioij8@5oy(){jKcsz)b`|DB@gjB`4|?MgtSf5S z?A0z;2#(SF0(QUa_$r;$US_}H)ksQsxdGE7LAv0=jh14gZt*IfA0Gusv1TCQuJ)o!$_(qj=0~keT4S8c#99)ihh<$19{$s%6g?Tf7M4K09g;klo zh3!UiPSK1bM1j0_c_%lyGb)7bi^6bH%K_>W#udfWLsBFUf5%Fo9T1!Hh)qSIy>~ro z8(amR`EYCfox6-L(evJWeFs4%Z#D;Qx|-F)n9AcH>7A%$4Ir024(c%{omPY{F@mhg z;vr|9@|YJ#?d)uY>niu>ywzt#_{hP*Ys+ey3 zrx@z3Kmc#^ZFUQ!X9MoP2x!%wUmZGrjuaeq)+|0OvVx^NF5rALg4Tcp|fB zz9JBSiG!bM#k?cbVz+ped!Np`|5&=oJa}X<=$8bU)FPXXAl$b(4gCB>f`k}tZg!@^ z8;2ra!E+mTsGQ1>3Y(taljy3Z(_FJ_G$3h+&fqHU-NCqfj-@pX0otiIhcEts#X6zg ziX=4CkSz+cAq-}|^upEClK(}@b|4~4KSgCmc9E`=a#pF?TqH%NK@)`8fVgQJQ1|zP zbqL-)LG=9}J9U1QQftEPQStifj3PHRXsqrfhU&r1{=jsQwpTU1439lY=4rzX@?ChrLa=7Kdp8Bi&FZR7(G~q8^%@cDiwKC^ZD`KI&Sha;d4{(iQYj zX%h?pke1@a$+`fzOpu2hc;&2^&HYT>R&z3Vr*(YdQ;)D;A5V7UIBJuRc>N~DnD}~Z zZ$cu8YNCI6sUZt(w7%4PXV2_C@az@r50iA#qWK_tB!CU*s6UHZ7)X=%Txer27FUa534MW z4NLyIGMUNu$DC~JHFH?VI(U7&7Dc%ylt2fPun>fKoYN;_LTfsx9m&4t?VlEu&KSL= z6_R|4T|?YYdlC1HrP~SOU1`B}dB|W7I{A zP5QXYw1jitdw22I*JeOp`F7PEkv!pDZRR#{2T+vr3dybp#QP@m(WWdQPBLBY!-!xO z?*J)PZw2~ktPphk30GuiQopcVBjq!#Y%JCp3G!+@Aji>?(?)1cY4#b3xB7@at09sQ> zPd$m*5EWpCOTxyD|4kk@J^yL%Q#U;(_a7`iOoL7&FL)95ht`7H;|q7d{QU?D0Y@RP z{oAbrDwzMyM$d3cW*#s{;+u*Ae1-+^qw*R?%>9F9$`4y@)AAd+9dH9u--np>paQ6x z?Axt>VwQh!%m2>LE_ez|E&xU`?x0s+QCrIfQ$*Y)qLDWCkY{zH5UBaUzzI12Ru5$_@? zmjiP`2wdmAx(0x~=dAy$6D2UTQt!p8(`Zg3HjkRG=0V<5SI_x5))o(kYnExMk9@#> zbF{+jIOlSiDi@g;dzfCOjRL#U4s;#0=G(FzU4s#YpQadejq{`V19h$rJ=t#v4SlUW zAzt@N$}%i3u0@@VH8y(9YiD}_9HS!v?-!G1yef#qr$ws{aM0a|dpg47bj1K~cd-Ol zc7QQZVkgh@XxBOQdaO@~ zlxYhWO|KjKR2l}cUhUn=zFSe4Mq4;Cn;g5~)|8DAfkH?J;W}p!t-4HciZs43nh9D1 zvoo)(&s`evR&fZ0NSig(j65mmBtXuU%E=Y;_UfKL%q)j}g%mb(!N=w#7!8OgBg7g) zhyC*FDlT^V<-tbxuVt^)E1Jet1kJ7GtRVv;O=Q!IZc14+4hteBl(uQjUIp8Mt!?eSya;UutuD}kAi0P?;u z-b6qz^<>$XA?w+KH@{LY#?AjqRa!bsx*!>Jbv7&Sm{SwqeAWZ>8R43hj#nqHi(N~& zizN(f`qJ(OuM-sMJh|i+BB;aUHYK?Od8{#v?TtW(+mu#__Dy%O!oq%U?H-|Tj}{Cr z+o{PET@X2H5|%=MBnV-b{&vkepcI<;mtlPN* zQvn1tY3t?$!yiA{TNJw(dAA`pXDkZs?C+$ZUBTT^D|j&@oqCP@oiaw?5(usbY+%87 zuBeN?6w5xmylgA;ScgzoN|Ab!{#f;ysKAz*=;Ekz7DlZ3Zbz@!`em+lsWhRCuY~OM za^^w%^;U~BwqchB?boB+yLBc=lU z7hIDPg`(RNo3n7<;BGh{cAw&nwfP=_4??H^!W#z@;vsWaT(ks^n6j`PE4FkM8qSd) zEd<%H<^gq&&uq!Gmv7F1P|V241qvi-LzV}64BE8D1;*A9evI^iLZvv?08>R55?_l$Uglcks}wc|^jtJIoN z;qJA0dg$R zV3blSJCGa!qHHEac+5iRHuvQ)r>&owGc&1yF{Yp;R=sD??n{wDb(V6qPU%GYDnu1|hzZxfHuj2Hw@F(4 z@^(@Dhlr2URrMd|1yO6!(Km9mYbNMt0MC+z&u|__PJk^;6%=mKrgM$Np!O9zPP`nP z?H26wdH;U;)6IKIU0c;VfY&A;q>D<{gm%mW2>!XShngqW@uE#X$; z*GJx;d*J-jllkg0%H~2pqEh4ynXLeoePy*|4u?4yTNwcCS7+vnwSAgHN$&JGdn6+asMmASsb~M?4O*t@ z4@&X>LmjVyu`QSw7g((`Nlkj+4 zTU@b&p!(+gU=V5;{9pBvhW|4|X6kt?VM~jHtj18>J{dR2*rCq zeY!jl{DAU?( zhmD#0RyxgvM3I2~wZng|K(5}ut-z;$zW{&gbKvin?M8SH97?1j$mf6or=GD>=fAdr zd(V&uz3@V0Ixrfv-21QkzCA~X?!x6Kz-3lu@!a?(DIy>DZqY%w0B!e9;i#-jeAdm< zxNCUDXw{gN%~F(Wx<@?UZNmYk78R>i;E^WOzZW<@R5fze0daMoIl<0IJmoxWoBi7^Om9N_81<6mZm3% z$uir3j_gZb*#^JsyAI&{wDSn759=1YrkE4aYDqR5pWdepL^CxIKL9e6?dN+3bf9-JGv$e7$vp>=P!k?LC8yA7|mR{rWc@n z9}3HNQgv3*7xkB)oLHwMrneP%hv(lnPfc)Wj)4sPSS?J!dkzLT*QDNu(vSVp;bD&3 zW7^lX!mi(YeZ$5OZ6@NWBrp+CC2Z{6=L}SzY zlaA-8E@FMQMu+m)ZgO@5*|$Hq+4Zp8|GQd;(b`&HC;+J724Zxi5I+u!00alRB6}JQ z|NBuBa9{6;12#y&lFxHh1NXJmKksV=1qBhs-iDfGDh;vUZ)mWB{nrtD85h}$V&+GL z;-mn}hr5-Ix32e~znm>rTW&rYi*&?>K#{-s#I^4(vH4Hy92_&f*WRXV?eXU>ja~n5 zb) zIHhmtHLNK-3T!oGCgYcKn7Z0|Sm=h$t)NB6E|&VlsV31lZ7~-!`1n#^HRo};GD&sGJqBjT*VaCHS$$bQ5)2@vPO$8`0!HrrpRWA^~!X=VT zY@@R~DZ`(Hf(`fiS#pCpjMU#c@RXEOJHH*I6n zu^5z`@5;MaOG``trRv7IkW!!V-)VDzZMdr0L75Tr|d+(^G|216{3!*3@D4?{cG?k`wq(oG@fb>pOI*1XZ1PF4G8x0#XB^2?R(G!WUBZ`TE;1LyeV(-WW#>84kuLrJr6yG_}wGS2RtK#6>Z06tB5KYCOPO+9xFK4irho?VL6WR zxyeH+(>Cs+E|8*MB}q+>9HlUO+A0OV@-OR5XW3u-GSjkO&-C*$+6PNF%|i1RLA^PU zur+3 zH#k=Kv9WL#Udr_twzDm4E^F{7HH1w{OBa+0{Mw&k1_cFeOzKas1%wS8W5j?W{4EeF z1WP>J2#rU=i6xpM0A{(%u87L$ukFhiUgy?_q@8eiboBM%r6Rv+f*Tm4z$KXC8dqGgw&i9soq$BVB<8U*Z}phQM3fl>MZQ+IozlQJ2Pe z&V{Jx9&wcJ@3p@{F(@WAt@9t$ErbP)T3Bpp_oPqQ%3|Weonqy@CT-KcZuXC^)2C9i zcKl+4Hj?*@Kuy8V6Hrnxaq}l^4ibqv53H0S56v5vCINb~PBU9Htr}*3ra({@xRu=^ z317#L5>8pMRvYq$Fj#@pw9B5bBVfX#smD6AwS+hTL9F=PAE}$xzL02AV=oCFH?Es8 zf=ZOldjH!1FyP|;)%TF?zYU1d?I1)of{aV3*9sfs45 z?(`1J-$B78G%QUjOXzGuVByn$;Vf5HT7UivdE%G-7vyQ6?4N-ru)++)o&Fm3e{_MV zS15$ZCro`YEaL^}d=8q8<%3avtND&PeT*?wl)GgT{XeBkg0&Hl1Gn6GobL>Snp2?j zo9|ipYR)1E>^4#Bnd#bH6DCww>!UC%cqQ>~EX$*B!!+RFtcWa3T;%*K_cdcWYQ`jw| z?Mm7)S!OKeuQdO{zo%iYED$zp879gg1?Wl1FQoOq2Q)~qe+PD4KVlTd6q(mNnbgkW zGWG*f1tppIQXqGuIyEmu8irk*-o3~b`8w+U<|;ahvDt_M^YB#~J)L1UA*X=IbJE*! zK~vE>{BA-~c(5taZtEt2AEYkld43-s?+|J!Z#+tjRh%f!e|xYsRH5IJl~jTZqoLdA zNpJhMaJDo%Jf{g1fV}byacqq#y5;VEB9L1DOCaM9>rAy`;ikk7yl6$D{28Db1@ zbGd5BQ<3Dae)=Gcy59aD;=aqwz7TiP-rOrN?%PnDeE!d%A*?dFzXt9Zq5x?c+5H>K zvpuYB+9K-PNU3aS9+;nF+XCImR^)E@|4Mf3|J3LHx8zNl%{u-<*<)}ae`n)&kz&Ej zh}%uIplzYG4PSvj?Y`WB|IeZLPPhL6#n=4vjrcE6{68$g0nj4+XKV0!NQ33|Gd zATyq4m~eAQPsCII=(*Hqm;);NhhQLSuY(h)VTKEX2rvFOn(}^sMyTg*u7PQ(Xcjtm zYn*wq#jDCcZw6aJVGMLkgHV09K+B(;P-x7?!9PdG_pgu&_2R$2{U7})m8{XhlI=iT z@I_tY*rrYGaQ892XY)t(*plNh2i8BWty=bEEzOKH{_DZW4mP$VVM=X7r}^Lj^bbMc zcTe*_oEV;YeEl*VV+f_OgOOuiLFI!=AqEb*fUyM&^UP!!JklYmCbUJU`9ME<`br2y z6i8xfuWjz{f|rMu;&0~bLI-cPF z(;$tFWJu#~Bky+;I&(yEwsrbXF*7sZr?i}sKeYD(YX0pYLsy$;Z|UEz`LF+c&Hq)M z|DUeRAEK8|b=cfHgP944+G{^Xo7=$v^!M`CSqU*%4}32onQ|Df{Bt9NBNKO-@X{O}v!v z_&)YJi{!7GhZ~s=AKe4@!HY6t52BH_Py^56F3T_mo%KV8L+jf{*9Fv6oN;wHEkRSA zCd)JaES6xtGrrS-?9fk`kYuv<2u8UNr9+dq@G^8!?uK2B5e#`CHN3zrm5 zdtRL^&%0r@WXrQFc}@Mw)t;|vV|6cymj}C^j09S_Cue^zvF7MCa-4=7oTh93V2UH} zH~mTNfhho)zc%a*Mi_p734asq3LQ7O!@#P`3u1JIx|<%q&UF4Ath)L>EX-t9Wi5yr z#CW|8MxLX;2@YcAW6`oUwPRf2W>*pI+y@q%MW2Q~zP_IZf8+M=ckC-yRqJYla@S3N zNBk@k9FX;ZF!|47{@<_v_j6$VjQN9b7{pm6i5%I~grf54vg;u?zC992Iw_hK-Emdp z?2}EWJ?((ms+9H-YVA5&KI2?Oq&T*^Bs3Tzh#iOaV7FOK=o}GXL-9%vFcIe30-a6F z7S9bhVFpCpbxU`jdr{9cDA@>ZgMaeHgoVVece}rOV+3`V6k7~K^ZL%oIcN%_hLqvo z2n*q{l1oYB1JoW$Wfv52eny zzM@_izplDB#g!y{{Yx6W6jW)ov(xG)l>@aI#KG+ro;gS@Zv8TNL1Sy#o+A0?q^0Y= zlx9oXQG%i$zK#0Uh&8Oj**vI^c*z|aM{82ld8Bj;nk&*Si+^M?;Y@wNc#k^$3o4|} zNd_SqH2=;3prg<<0|vU#Hx2lQj$MB9+_fJ5d`a!ow;Y8@W-|Nb6n)SCSH6E3{1Fq5G?9SIp)HWG~Kx@axT{^#SwO8VAWn9Od3>~hA`wxWebY5T9A6!|qGdP$T~gw6 zmZE9Bb=96>yOSFsyN%Oocd{FTaTxeU-6rUz$qsn)`=0OySH^adI6O0*s>)Z=<)-b%SFJalz11%AcJw71^r?`dG*nh4xS|&$ zN-UeSVIO~eH#Rsk)D%ea-?okd({)N{FAitwFyjT2pKd`pf-PLR-jh(9Ts&>NLSyzn z?AE;-)AIVm!9@Lq|IzJb(NYcggzot@ z?9;Eo7kx-!P1*=3X@zD0WOYO8IJ-EXw_b_5J)$TZ6=-K%*vO1>m(EW68WMiTYSczF z(=C<=JzsUuic@f~{z=1vJu#tv3R8IoDW2I ztgez{h0pGd()FBNv4mRY9oY*<^oVISTV$o*!+tyGMoal02S`*~yQQx5z55~36sWc; z*^9on3GU!^OBx?t8b~X*{pq=nOKg^RE}yZtiQ@fks4giYdVf62L;6{|#7y&M2cfPf zN!^Hcjs6fQdAlMkI3}g8T<+})$aPaUj5G!Mq58MHrK$%6{c2vrec+BqZ}3Y*yM(O{ z^DqalB$pOl1o5fKa`BdcmL_rPodHf7&~CA_>?xb+gF23)+Si6@Xd?zlt~6795X$5L zLMaj5ke^Hl`ZPjktaGB_MW&>Xy8a{a)CiGlHs4(qU(KF0y2t#CuLWy5@IDC_2}Fsg z+7^*^{87crSxm0o5z^AfQM$E9nf3Z=sg%C+iRY)e8^3P63glXl_B2b)wX$$+4kFdz zh{M&>iwQ=^X7Y-DnIyheq;(9Z(z%KsbS7Rp(3O2=q9Zk)$<5|jmLYd9T;55DrnQExgM5;maG#|%xl%;5C^-?7)7{?zv>o6Bp9@9!)RxQX zEn>XCfJs4s7FZXnF(CR*1atCp8`q%8Osdt?(4tA+TlMwp)|>@sQs!U6S?jSkAWG>YP(-y;*+r6tKUJ8XgottZ~N;{$Eew zf5}s_?nGBl#r|nvS@&Q=AS#^B*Ny%OR>^X~9$6os!;VMH6@|A-5D=3(K+vqCM0e)f zt_{5F+lk55jEJI58MAAWJJ8?+uA#h?aTvu2m8;=tAhd@*)(xZiw7S1!e%?>OcX3Ye z&Evb{_U7Vr9Zivkavkr?U5GRM9{(jsX}&#jMB>LtR<6BA(hbSz8b)3#7jpzwEAgBu z=8Zi?=j4n)2~B^2JX7cm$a)e~u{w5sBHQHijEjg%5^LH0AUsy|)m~m@aUQ5l`ignl z0m0EMF*liXc)Cl9kDsUOx%tZOWyxAh*~^cY=BCGAT~&+Y_A`1ceEbhQ!u$r~pxYOD zGlhTyWD~71|Ip`6AAb069`a1b1)5*z>pfHTNl3`#LTWh2ne9~7`dGtdYJpG1^g`oh zrJ+V69!2xT86yV4O?jr*PPWl%yf)PJKgc>kxIlZO|IQM8x0k5)*$;<-VOX~b3`jO{ zN~8FLU3&gF%LzzDvKMn#7tqR|qTe3n19Wl&#fP?^!$H2|1BXD5i<)U`P%fw&Bofcp z>|6lEbzwQxIfzN8Jlq4bN>=wd%6FD>!tQ5UxF<0WYWrVT?pWvSXq6sSToaP%8?&RZ zf67cF`5sVQ{Zw$l`q8_KHLOBMmUa}(2PL;tv4k@j*~11-CDIiU@o7&Yk zF7v+|_AY}(cHoDMOeGUY_B6BA85ux!);@u+D;;CkOZ>&W2yYzrSCP3<70v-Ask53d zT@T*#bWi(opw4=GlX{cF7uRe_Q$pON^$4FEMqVK5D{R{*$aGjUN5c=p(#83NE$rjE zxjz3?N|yv(iH;rur$1-x%*h|>YQzLiU5Z51jz@FWy1Zpg;?}n<^@wUcmlKH%EOXBU z*?-j5#kP;CoKIA_|KhN35C03BA17E?W?57|B~ds#tTSb!6|$8b z26ql`Rg@_nw`iauYh2||kD4``uuKpB5pYf%BcKLu2QTl}7os_BS!LJ3p~n9F&!@ zr6{F1g9XFH!lwJPEkT{i-y?aIq3)T~P1A&=Y5d2Ju`sa#{R-t%KYtQj5OAsm;5>_G zu?0+S0im<~Z0-A~e0Zt7C%;ny+}W9Ry17^VblJ5lZ!xO*`aD%HKNl7A4AO4_-XKL0 zb>`#uX7k(?O_9Mzv9+Vu)AAhcKlimI$Et|Ja?&#rrv7|0z#Zu#ZI|JVolUkT! zD-JyrJs)3w789uTz24mC^a zaXL+2f02>UtT=z{p5>@SJw6km+4KJM+u&V2pTyp*CB%3CyT#a0Yc=xP9OMO)>*usR z;Cy4qUuCo4^mJI_xN~U3!55s82O4$QS*AMQu*g4^%qvNKlg}d&n3lD=UPm9Q#@jot z{+vHkAi?7@y*{ap279U(kZScn9tgxA22i%;eo0 zK*<~kOLp-KClQS}@0!T08IMTiH`g}6Hk!?j=m>Z6FL#*+cznaq^ebCz2*+*B%K0lv zwajHuabY}k9+gi%#N?m==0u?b&RJbgkV=CJ@DuG*aoKMBm!xWeff~nwqnZ&Cb)onK z6#ELk#+rUw0!BWWx-OnII}o`Wa@$;gclng(w@7P^>CZAfP1h8EX+jvUS_OeyZ4WvpqHXPzZTRUK)3B1`YN>S3@@xkiGWGC}n)}7!AyQRyb z0Ri=~tO0(|fR_i(-i7Kh&mu37v2C(!9diU$0M^mUMP}3^v*opi?+=rR1CCX<-$%Ut zB?+0slknvE+0AJ2k!Fcm*fHRV*=v_uPl`MrPkxS*+{0YqF|Bg!rdob-+l!XSoGEaX za=c?Ooavi^ZCsdw1&GO5FNO(`J``#996zB(yY>c6zSMO&SmonySv9ZnCygK-;mwrf zETUBs022`NdkTX@jiaj&a^9E`?J7R5pJq*6Ng>gK-22CWKcr0tcS~-@UP?^6Z@m{> zyV-y27I_K(Sz^;H4Y~y4(7o>GQ0n!2SVd;Pd1W_j^47e2*SXjBdgLs7x*VBalsf)^ zW2WRfgt}RQ`U-3oR2Cp>z+09rVm66>MhOarCtP@ULNvc@G~Q$2Bv5H+!VUvqPQmc? zT>D=bhOD&d-&g`X>f7DY3KMRy=Sv{084A@OB8+14aghRu@d>l4w|5<1)hMy~bz(v> zM!H2Ff!Eb*dOh8}y>?BO?tJeoc7tzTJCw5FQMA$b2A`5vNs;K6SSZ}fg3Wl_zc2~5 zfDHtIgxAUR%e%g@v8`E-Vg;>yppl9~w^=g}0?tIMBJ!awP@GA5R=+~}CSuYww2+TK zEH$5_X!6xa?DV(irOLv$AKEjVbvS?Et9HASqOihf_x?kUfQhIwTmC|`Am7## zoc(;Pn?-_!Zb-Wd>M*vlGHNR5`hVK9JR{ViyS%CBw{|VDyL%Ert60USAE{(MBe*(Rm2^LnvhsR%w+21ZcMqM>dH{ zA2NB9-vbdH>82~<>m%9D7^1-VO-ym;3EuPE2UzCo0njwFveW>}v`2I+K!WqBdT8!6 zQW96613R^9C2W;ay4PlWK1Eexsc(7neZ$$wejOR(L69TUYX;vig1(kRzTX1L&%IfO zo1T@)+dOAX_3r;uB|fOhb_d<&PZuH|tLwjY6VRaS5^>@5%jzGzTjj{;7pwzOq!UtB zVHxIz+yjepFE`I#yL>qK^8VzHN}xCBVg$&Qj&OSynGQ6FMsn_ zmuRHTWNNY?&-r~9?xUhbn_b_o7r=Ymf9-2vZ(ti_Y#(g`k6NrP8&^Xt|G45j%aFRU zb?v#NpOsW^N=BFLIei6`5ZUR?{)?ecXZj!O@LY($#?mVqAN}^_ag_yYU2-U~HHI=d zryEK4oGb0ZgVr}r;Y*mk@kiL6_u;{^=&;1mb3%#QT%*(#?>#3fjl`TyPsiI&E_yS@ zV8KIw&sl?x-U_Ky?<0SDawDLfhH+m=DRl_n*7e- z+iLFX#+(DiC1zJ0hOV4hRQ@6~@b*%L`z)^W3pxSHhLo6>G_);SN80Awd0(CUdQ1PH z!_AZ{*93VFd{UXe_z^oZ4+I(W;e56D6)*;t**vC} z`chw6O2BoDdozu>aw>HpUub+K)jAo~4yivGM?*4uXE?zU^J!U&L4Vk}4vnsYoILaO zyYGUy+i3kSQm<{rd=fn|FWwSZTVi}S<#}4>;OlnZHx~JSs!spjr7|x$+oD+XV9z0m z%8)(p`6xP11gMj75MR%1vLVqtJz)o`A!60d1>+1RcOsf4H5bA}4YYI^f2hL09+11ntjKb*Gn2g+T*(A5`D`l7h3c<& zIsrV(1v=VWAt{~8yNw%Ip0sx_W26Vw(|+??pOoQ=kh}D0BZOxhLSc&)h|P0rj_kKw%LP5bsy`U$7pT`&(~x3fZXnRJ!Wo77WoQp4FS{KA6_xPLGrc^hcum=!-$eXqM#<6muOur4 zODWR(W0hh*K+$QPlh zrfcUDg@=<*Suh7gHVxC@p&>1#rY8R>v5gA%y2`E1mF4=Rs%?-$1x}6?2usBBbGD&b zymBs4bPA3nz5Vs!^~T2HiFU=$EUb5JU%z@`0e%mEs(&yAV?gp$A|OxPS@HkltTccxpE03Rh~u7oqWSQ>Q~9Nq7`I3y}yIlRX%0)l4yOn~-VA z_29EBpLq?3mq{5S2=0#y#vcS}Pv<<#60X}EdAHJca>!~Bnqm!$kdIHTOA2iD9A{p- zl$Y%2fPAMc`ZzQe^CGV`jPU|L`<54tlWCynbPs22|^g28uV`T9k_jo7S(OKKVBHT1jxikCY%((W-wGGQcVvb-Je ztO5b=b`l??L%pTfw}l0U)2XXRFWwTxRM+IazfvMv!=6uLW&WIyU1!iF5V}NAG_u@W zdl2a=_}k8QRZMe)UE5a*(!4dE%Kbp%K=iq8Bd*Ug|U};M&j;8)XvUs{|Dd0`U{?H0RhVbpQC5fuS>Sb zNI$zSU-3;D)uQy_tYh##k2w0nNPjq&!25Xp>rO)Y&6sH77XlBsy)iH|+RH+W=h8-%s=8*8^hkL=A+)fZ-i&P9y^Nlr)PM0T*$Pmg2h@hc z;;h?~ey$Oh*PnP(M7ls_-3nJfG9YH5gvE03`!H=N%tc zKShoDZDMt@IrLEnTd~}#io#tyE*rl*h2LJhSZcqz3UgELb$T_gN z9-H3g%E>en_Tfr{h&A>h%%)fn-!N&b5@Sql$^?6XUa3gTOv-<3<`~idwVJ3Sh25vH zG1WKx?|-EEyB+X=J9is)6^11d2=usYQ@01kv9`Gc~*}(6PwmxXD6msM!6b%c^14C9uFwU z*4Z2#d?i^C5(@^EymYW2wG9f$5cZdnnk!Ho5gw*o2LAVf{9Zdew)+2SzhQ&X<{RsA6>#AW< z-jm&f6(cuM6oKgV6bQ)~JixaoQR|@1VC4YjKzcWBZX+sp6>N6C7W%uyqmh(BSrbO5 ze}12$#hGqkfDis4AfmEYx(owEm-697=(Y|tt~=XT;W1%GDftO&Q~kJj%-u%;P6fKJ zh4ofi!n7?xOXQ5GyhDM}1siBS4xS+|a&jL{@TQ$gpK(n-N3_>lWQhnjZ8QI+#g=&$ z)`EBh-wVF^1Wh#5-3n9MI^n(n%0ZDVpIZa=31j`r%S%BMvUEO4Vo?`-qGvv+4SIn2 zwXq(QSDv%V7Npu)?#8T((EQ_XoqP0%Pl-x7Iw4(*>Dy9&|F(Fhmqa)Toy{klQn#Hm zd4W1UqIvq(TjmgitJc0HaJS7avSz`P$y-(bs(p71r+=ne_jFafF8}Q<`QDnRW%EmT zKwQ3(4(iwj#DfLkZol53C(lYgW7JhJ`4O_rlRkqF!TKT}$gNf&m~v|_tv+EcZW`fi zQiD%ie61CD;cjir6_@1S#;=1#i7K8Iia8qyXMBo1mf9) zpSz_@PMCGbMY{=xpY7~OO2ti1u2nq zye%UIEYbnJLi72aj+R@7faPp7>asWgn?U9nqbu!~71O>U*NV5=Ff(W4G5Rt$--oNZ zS;ET~k{=|zer;*tf_cpH-b*eowJxM^Uz4DwfYtTh@zh>;j}t1(+-Ra_Svy)ybAe$8 z{S21LZcYZ(8$@U_Q=W|jKhw7+Z!2jElEYrq7(BARAA5G{N?IgAO z$0zG?<@y{5fSZkr^N!vIITugta0s`A&Y*Pb@AZ~xQI}SO4-3Z9LANd93EEvxlVufJ zXG6~X0%;ezMeN$agG_eP^z~_fqa}{A$KETk3rs#_5LvgIItn7&_vey$Atwo%8SfJy z=8-g-(z@pM>EMSm4iSy@N4Lwd{D_`AAjr+IhK*L9ANCL99C`cHW+oQ)svbW@j;8YFisFlF95Yf%l>+(g+-!W zo4&-Qk*%<~Q^0c1kiDK|s$8#U{vR8$q~TFs~b2*R|K#$ar%u{ zJ@cF61F(Ac_g~HZ~g_4bec^WzkfAR7U3Vxtc0XY`w7@ z*{G*~2CcgoC2k%@>ZnA*0X0x2BLvssDAT=WIe4BTj(g%>i5UI%Q`NKFC$~P?C;e{1 z!rr%m^AmO|9qfBJCi^Dsp$9nRyPJR#qzA>-)tQUeAcVFl@NehD{1U!6EZUf;mLTqZ zDV;a??n^6W3vNklYU$4B^T9Z}pezO70ct#Q(oZSRE61oy`-<&Gxit;1KMXka)_%2= zx5^_yqv;^>^YPLbDNpB)%iJwhbXTzCF^k>Bq)P3N8%Yw}zh;Jr);13PdQ(_hYL;NV z(bQCtf$Nx7sofbH$X9i={?p5mMpXqr|7HHSg24w|IZbw+Pj;fn5u*Bn& zzHs;n9?DfEr$$}Ndjpo$)XUO&%%J07Z?!IGuw}gNTz3Nn(ms*+%#(QG zj9o!RX1x+l@>u-0A$k2bU*ASQ$Plns`2<$WwrRby0{6fJ#qOyl``L};h#by+=|agb zXph%t&}~?ycOSs2AKknY%dIpmN7a6Pt$7z*k?8Pd|GEs;Xtutt;v2H?joSy`P$Px0 zqM8~<_$J<7WCrYnFo`49jnPkK!Y2;RWfw&vm5KdNKBc#cH+ip>yz#i}Fe!zpduub0 z{W$W5$ZsWa)C1$`1Wr1)Gc}pw-nI#d20d%Nh;#+rGI?#_$s9hazH!KVv~hhV_x+4c z6&z#u(WNfIOh@pUU=43zkLuw)Lie^ZgzOo;)eVxgs_Z=Xf@f_eQ|P6=XfGjI3ksfo zZho!_&e2OPEk+6dvc{ddG5IciXmn#_;{%(Rsb$j8U5$jD4 zP^4GFcX5SyA%dFKYd{^u&p{2FYVpUt!Zc{s=r$9k8tN;)x*xyJA<+goHiLvj3OE{? z4lYnE27&egqXrk7jhqW!K4#k7BI?H#vb1_$ckoa-qhVxANR$&F1H%-cTYjG#X?D<* zGjzT`3*-EGMZAD`oK{6Ey(<)c+kEM_tU7JJ4q(e`l z&P@Np@r%k5xdH^W6hc#@!wLo7_DA=rJly;w8F|i(<1d`_Ixs#984o3brtdV^g#!|S zRFsH>x?v9j9X220NTZtCFKU%J_;t}I3`{mewS3iYKm8VMbK+|MXc0dG`@VPldUBHT zR#$!Y%s`g0uFqtkw{|u^rbO!j_sC7->4kj%^2Yk+KAJ5o%bKdYt_=DH8bBTG5K;%Q zAe?PI1GueyBJkAxq$^W553gB=6fD1}be&w3Za4Dgo+@>1^`O_#aP9Rha8?GWLhp&4 z;2&U~pU2Fby_R-yP2&;c(iQZ(61iAS zvfg$Fm%VPe1J8<-8b;m+3xIW!%RKh!A0|Cuy>`#SaN^~DfKr*%$d`7)S^BdhO2i z!z^AG=*zwzE#aerv)McJh;iDOAmQ#dxCyNTv8Cfg2~rHr2lWkh6!gV6+i;@<;f?Oh z{lW~QVT^!wLsBmvi>_E+@L=SthY#+45PUCuWpjNUoHI>I5lNBePGRXRNr2OO(^*GAO0`hs`G;#y#cJDq2qd2G7G!O^~> zNO#^*XL=*&#sz)#wZlK{-}(?_G!Vu3*;zEt3YZ{u2hGav5WZFlyv@p6MH%nCVXDYq z4??pw%yb+UEnnLlM2Oj+Yb$BPdwke{c{RVK&m+c>DIw*d)m56yb*40h;EYsE4qln3 za5{MtiYub?!t5Eb&`0POkRw^MmC<#dH7`5rpTw5;ytkKM6}D-M9U7G*Ill9Fso3$U z6z{vIb-##AW3q=*)aG>C2jZM$7=@Z_4)hCEWVcH9LWI@iozdLULdu(rhtA;>KPpCo z<0HkU)9&;=srllkew=yy$SR)fga#zK^&k|v(d�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�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 z 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

-(K(ms8=k6f-SIi$wqT48v-=|re)8tet zdq!U>8QTRyYw!2dtf1xWK*Kf+ZwU?LbK;o1C-z0e@;k z1Mb|FllF(r;(hHTj{6^k11sz^HQ@S=QFz~m^$Sk+XKwL^UKh#?ubZt(SN*6r|B+tv zI3p>Kv{A#YMgp7+13V&%6Orm`8;3h_PL;lMti@soMG(RGOKa7jQ)*7ud;%$owegre0SWA?ODT9ZNaHK%C~#x% zf9>bojALh+oAaszf7Xm^<>Jd5kxyRV7WY`PvdcJ^*K@NNk-UXtpR@9;wY?^vYkV{&y5 zM{IX8z$u(!W)GfBskuZC{vGWom?e8Z&gjlHIf&WF9n~ic!mcA;vjNfG!Y>&-)8fCm z6tPKINoS}sV^N~WbIe>n)+z4;?qBoV+v=<8@#@M@*KZneLIaDJaJ3pgcG@04lx8G%%H;R%7H82m?zdz?JKWYDM3zwXg-mt3dE^K3R{#kBT^5NKX z!ZTMjei;h)BKMBnkfsl(0h zqVLTtx~F9N>(+-hbXpEr=z>l(AnZe;A$}5PMq;NGo9b;T<(rq56;Cgj@2^}9zMp54t;c=6$c?a6XL zUY;^G70rS;GK8iyXTJ;|=1}W*X#Jq4V2`C<9$N!x>~T6ZULsCYfUZzAL^rRR3$qyZ zWyhE-wx2uGd*u7OHWd@Ia;62n6H&KHhjxE~LgZQSxkG$|{_I6K$t41=c-p^dNTE)t zu)u9E(0u2UJ$r|Eb&|5=gRoG9%C$S9XzvlaegOgryS2H)E+wGI)?QUKX;@bEBad6; zcAJOS5jAZi=F*V|hEGbZx7HVyCs3U~jGxq0f1KDqKtmW{wnWy)zJ^rwukC!MOH(>u zDNJm#AZ$|&xTi;5OO^&jjpNBiXG@AYB|InnlT#;Q(f^dm$L&z;2&hg2)wEz`)Q$_E zY+k7vkssu%I5LrQIb+4;L$^<}@!gWOKVjjIq*m4~&8vP@E@Nd&-PhLE-*zi(cZnp3 zU9|cz!=zzgm{5MD{B2L`rLbn6(hZ`;5YpYcZ~sr#mTg#$ZL)s1#^Bb-@*)kbKz9a< zbBBHeY-vhjD-uF63Rl+(AoVi`IJ5$lQ01}8zhr7$p;mds^zJRpdtTEhW1V9$yFid5 zn2(Ha!@sGSK0#4RWQlILTKF4aob7(YI1WOmo8#P57O!s!9hv@NBcjr;IBcw!H@ik` zEzMFS8fi8*D;2Estq^vG+=J_cc^Lf=GMsZQl6E85u7=VXjyr3kR9`T~KBY!8wP+k( z_vwD!)9~oYL&}E95ep}0lOrZl>u!;chTZ>WUX}4Eh!_Re^!K=$(xwjhwe=S@_1ifMxI5$k_(l4JWP$6VrNx0<; zv(z`E3zbSPcHm(&I&Ur**@F6CaSZ|h^sRI#y1MRCRZ~fYGd1(`bqp^go@(d ztdyMP-Io&`gOx8o#Xn3^NVt7YFg@B?Ge26QP;|J`_@YQxwXN#$sx&ns-n0{FF%y3) z&!W}?FjRkQ3OaHT&V$f2)qG9TkTR|v^ljwrhhEtlnM*g@Gic}ZRfNx=)}GA>zp_&t zZZOaLy;4^buNdoCj-$v%`37NNkF%#9mrulI7K$QfPI=F_nW%5qsE>VF$-tCG78nGC zc*9*>JtFj6{@tOHD^>|i$A*VjW{2heZNh}VI#X01B89lQIw`gp3>~S0dQX&XyR4^x zlN?87p#=p$_!aC#k!bc>5t1g)-z5w^9`X(96aU-9rm5cxyZ&N1^@WFM=^{bf(2)!@ z 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

l3^V_p?oN__PzE4kEjjaBZDgkT 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 J_Wd{ge*n{08wCIW literal 0 HcmV?d00001 diff --git a/benchmark/PaddleOCR_DBNet/models/__init__.py b/benchmark/PaddleOCR_DBNet/models/__init__.py new file mode 100644 index 00000000..26ff73ff --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/models/__init__.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# @Time : 2019/8/23 21:55 +# @Author : zhoujun +import copy +from .model import Model +from .losses import build_loss + +__all__ = ['build_loss', 'build_model'] +support_model = ['Model'] + + +def build_model(config): + """ + get architecture model class + """ + copy_config = copy.deepcopy(config) + arch_type = copy_config.pop('type') + assert arch_type in support_model, f'{arch_type} is not developed yet!, only {support_model} are support now' + arch_model = eval(arch_type)(copy_config) + return arch_model diff --git a/benchmark/PaddleOCR_DBNet/models/backbone/__init__.py b/benchmark/PaddleOCR_DBNet/models/backbone/__init__.py new file mode 100644 index 00000000..740c8d5f --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/models/backbone/__init__.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# @Time : 2019/8/23 21:54 +# @Author : zhoujun + +from .resnet import * + +__all__ = ['build_backbone'] + +support_backbone = [ + 'resnet18', 'deformable_resnet18', 'deformable_resnet50', 'resnet50', + 'resnet34', 'resnet101', 'resnet152' +] + + +def build_backbone(backbone_name, **kwargs): + assert backbone_name in support_backbone, f'all support backbone is {support_backbone}' + backbone = eval(backbone_name)(**kwargs) + return backbone diff --git a/benchmark/PaddleOCR_DBNet/models/backbone/resnet.py b/benchmark/PaddleOCR_DBNet/models/backbone/resnet.py new file mode 100644 index 00000000..9b30b382 --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/models/backbone/resnet.py @@ -0,0 +1,375 @@ +import math +import paddle +from paddle import nn + +BatchNorm2d = nn.BatchNorm2D + +__all__ = [ + 'ResNet', 'resnet18', 'resnet34', 'resnet50', 'resnet101', + 'deformable_resnet18', 'deformable_resnet50', 'resnet152' +] + +model_urls = { + 'resnet18': 'https://download.pytorch.org/models/resnet18-5c106cde.pth', + 'resnet34': 'https://download.pytorch.org/models/resnet34-333f7ec4.pth', + 'resnet50': 'https://download.pytorch.org/models/resnet50-19c8e357.pth', + 'resnet101': 'https://download.pytorch.org/models/resnet101-5d3b4d8f.pth', + 'resnet152': 'https://download.pytorch.org/models/resnet152-b121ed2d.pth', +} + + +def constant_init(module, constant, bias=0): + module.weight = paddle.create_parameter( + shape=module.weight.shape, + dtype='float32', + default_initializer=paddle.nn.initializer.Constant(constant)) + if hasattr(module, 'bias'): + module.bias = paddle.create_parameter( + shape=module.bias.shape, + dtype='float32', + default_initializer=paddle.nn.initializer.Constant(bias)) + + +def conv3x3(in_planes, out_planes, stride=1): + """3x3 convolution with padding""" + return nn.Conv2D( + in_planes, + out_planes, + kernel_size=3, + stride=stride, + padding=1, + bias_attr=False) + + +class BasicBlock(nn.Layer): + expansion = 1 + + def __init__(self, inplanes, planes, stride=1, downsample=None, dcn=None): + super(BasicBlock, self).__init__() + self.with_dcn = dcn is not None + self.conv1 = conv3x3(inplanes, planes, stride) + self.bn1 = BatchNorm2d(planes, momentum=0.1) + self.relu = nn.ReLU() + self.with_modulated_dcn = False + if not self.with_dcn: + self.conv2 = nn.Conv2D( + planes, planes, kernel_size=3, padding=1, bias_attr=False) + else: + from paddle.version.ops import DeformConv2D + deformable_groups = dcn.get('deformable_groups', 1) + offset_channels = 18 + self.conv2_offset = nn.Conv2D( + planes, + deformable_groups * offset_channels, + kernel_size=3, + padding=1) + self.conv2 = DeformConv2D( + planes, planes, kernel_size=3, padding=1, bias_attr=False) + self.bn2 = BatchNorm2d(planes, momentum=0.1) + self.downsample = downsample + self.stride = stride + + def forward(self, x): + residual = x + + out = self.conv1(x) + out = self.bn1(out) + out = self.relu(out) + + # out = self.conv2(out) + if not self.with_dcn: + out = self.conv2(out) + else: + offset = self.conv2_offset(out) + out = self.conv2(out, offset) + out = self.bn2(out) + + if self.downsample is not None: + residual = self.downsample(x) + + out += residual + out = self.relu(out) + + return out + + +class Bottleneck(nn.Layer): + expansion = 4 + + def __init__(self, inplanes, planes, stride=1, downsample=None, dcn=None): + super(Bottleneck, self).__init__() + self.with_dcn = dcn is not None + self.conv1 = nn.Conv2D(inplanes, planes, kernel_size=1, bias_attr=False) + self.bn1 = BatchNorm2d(planes, momentum=0.1) + self.with_modulated_dcn = False + if not self.with_dcn: + self.conv2 = nn.Conv2D( + planes, + planes, + kernel_size=3, + stride=stride, + padding=1, + bias_attr=False) + else: + deformable_groups = dcn.get('deformable_groups', 1) + from paddle.vision.ops import DeformConv2D + offset_channels = 18 + self.conv2_offset = nn.Conv2D( + planes, + deformable_groups * offset_channels, + stride=stride, + kernel_size=3, + padding=1) + self.conv2 = DeformConv2D( + planes, + planes, + kernel_size=3, + padding=1, + stride=stride, + bias_attr=False) + self.bn2 = BatchNorm2d(planes, momentum=0.1) + self.conv3 = nn.Conv2D( + planes, planes * 4, kernel_size=1, bias_attr=False) + self.bn3 = BatchNorm2d(planes * 4, momentum=0.1) + self.relu = nn.ReLU() + self.downsample = downsample + self.stride = stride + self.dcn = dcn + self.with_dcn = dcn is not None + + def forward(self, x): + residual = x + + out = self.conv1(x) + out = self.bn1(out) + out = self.relu(out) + + # out = self.conv2(out) + if not self.with_dcn: + out = self.conv2(out) + else: + offset = self.conv2_offset(out) + out = self.conv2(out, offset) + out = self.bn2(out) + out = self.relu(out) + + out = self.conv3(out) + out = self.bn3(out) + + if self.downsample is not None: + residual = self.downsample(x) + + out += residual + out = self.relu(out) + + return out + + +class ResNet(nn.Layer): + def __init__(self, block, layers, in_channels=3, dcn=None): + self.dcn = dcn + self.inplanes = 64 + super(ResNet, self).__init__() + self.out_channels = [] + self.conv1 = nn.Conv2D( + in_channels, + 64, + kernel_size=7, + stride=2, + padding=3, + bias_attr=False) + self.bn1 = BatchNorm2d(64, momentum=0.1) + self.relu = nn.ReLU() + self.maxpool = nn.MaxPool2D(kernel_size=3, stride=2, padding=1) + self.layer1 = self._make_layer(block, 64, layers[0]) + self.layer2 = self._make_layer(block, 128, layers[1], stride=2, dcn=dcn) + self.layer3 = self._make_layer(block, 256, layers[2], stride=2, dcn=dcn) + self.layer4 = self._make_layer(block, 512, layers[3], stride=2, dcn=dcn) + + if self.dcn is not None: + for m in self.modules(): + if isinstance(m, Bottleneck) or isinstance(m, BasicBlock): + if hasattr(m, 'conv2_offset'): + constant_init(m.conv2_offset, 0) + + def _make_layer(self, block, planes, blocks, stride=1, dcn=None): + downsample = None + if stride != 1 or self.inplanes != planes * block.expansion: + downsample = nn.Sequential( + nn.Conv2D( + self.inplanes, + planes * block.expansion, + kernel_size=1, + stride=stride, + bias_attr=False), + BatchNorm2d( + planes * block.expansion, momentum=0.1), ) + + layers = [] + layers.append(block(self.inplanes, planes, stride, downsample, dcn=dcn)) + self.inplanes = planes * block.expansion + for i in range(1, blocks): + layers.append(block(self.inplanes, planes, dcn=dcn)) + self.out_channels.append(planes * block.expansion) + return nn.Sequential(*layers) + + def forward(self, x): + x = self.conv1(x) + x = self.bn1(x) + x = self.relu(x) + x = self.maxpool(x) + + x2 = self.layer1(x) + x3 = self.layer2(x2) + x4 = self.layer3(x3) + x5 = self.layer4(x4) + + return x2, x3, x4, x5 + + +def load_torch_params(paddle_model, torch_patams): + paddle_params = paddle_model.state_dict() + + fc_names = ['classifier'] + for key, torch_value in torch_patams.items(): + if 'num_batches_tracked' in key: + continue + key = key.replace("running_var", "_variance").replace( + "running_mean", "_mean").replace("module.", "") + torch_value = torch_value.detach().cpu().numpy() + if key in paddle_params: + flag = [i in key for i in fc_names] + if any(flag) and "weight" in key: # ignore bias + new_shape = [1, 0] + list(range(2, torch_value.ndim)) + print( + f"name: {key}, ori shape: {torch_value.shape}, new shape: {torch_value.transpose(new_shape).shape}" + ) + torch_value = torch_value.transpose(new_shape) + paddle_params[key] = torch_value + else: + print(f'{key} not in paddle') + paddle_model.set_state_dict(paddle_params) + + +def load_models(model, model_name): + import torch.utils.model_zoo as model_zoo + torch_patams = model_zoo.load_url(model_urls[model_name]) + load_torch_params(model, torch_patams) + + +def resnet18(pretrained=True, **kwargs): + """Constructs a ResNet-18 model. + Args: + pretrained (bool): If True, returns a model pre-trained on ImageNet + """ + model = ResNet(BasicBlock, [2, 2, 2, 2], **kwargs) + if pretrained: + assert kwargs.get( + 'in_channels', + 3) == 3, 'in_channels must be 3 whem pretrained is True' + print('load from imagenet') + load_models(model, 'resnet18') + return model + + +def deformable_resnet18(pretrained=True, **kwargs): + """Constructs a ResNet-18 model. + Args: + pretrained (bool): If True, returns a model pre-trained on ImageNet + """ + model = ResNet( + BasicBlock, [2, 2, 2, 2], dcn=dict(deformable_groups=1), **kwargs) + if pretrained: + assert kwargs.get( + 'in_channels', + 3) == 3, 'in_channels must be 3 whem pretrained is True' + print('load from imagenet') + model.load_state_dict( + model_zoo.load_url(model_urls['resnet18']), strict=False) + return model + + +def resnet34(pretrained=True, **kwargs): + """Constructs a ResNet-34 model. + Args: + pretrained (bool): If True, returns a model pre-trained on ImageNet + """ + model = ResNet(BasicBlock, [3, 4, 6, 3], **kwargs) + if pretrained: + assert kwargs.get( + 'in_channels', + 3) == 3, 'in_channels must be 3 whem pretrained is True' + model.load_state_dict( + model_zoo.load_url(model_urls['resnet34']), strict=False) + return model + + +def resnet50(pretrained=True, **kwargs): + """Constructs a ResNet-50 model. + Args: + pretrained (bool): If True, returns a model pre-trained on ImageNet + """ + model = ResNet(Bottleneck, [3, 4, 6, 3], **kwargs) + if pretrained: + assert kwargs.get( + 'in_channels', + 3) == 3, 'in_channels must be 3 whem pretrained is True' + load_models(model, 'resnet50') + return model + + +def deformable_resnet50(pretrained=True, **kwargs): + """Constructs a ResNet-50 model with deformable conv. + Args: + pretrained (bool): If True, returns a model pre-trained on ImageNet + """ + model = ResNet( + Bottleneck, [3, 4, 6, 3], dcn=dict(deformable_groups=1), **kwargs) + if pretrained: + assert kwargs.get( + 'in_channels', + 3) == 3, 'in_channels must be 3 whem pretrained is True' + model.load_state_dict( + model_zoo.load_url(model_urls['resnet50']), strict=False) + return model + + +def resnet101(pretrained=True, **kwargs): + """Constructs a ResNet-101 model. + Args: + pretrained (bool): If True, returns a model pre-trained on ImageNet + """ + model = ResNet(Bottleneck, [3, 4, 23, 3], **kwargs) + if pretrained: + assert kwargs.get( + 'in_channels', + 3) == 3, 'in_channels must be 3 whem pretrained is True' + model.load_state_dict( + model_zoo.load_url(model_urls['resnet101']), strict=False) + return model + + +def resnet152(pretrained=True, **kwargs): + """Constructs a ResNet-152 model. + Args: + pretrained (bool): If True, returns a model pre-trained on ImageNet + """ + model = ResNet(Bottleneck, [3, 8, 36, 3], **kwargs) + if pretrained: + assert kwargs.get( + 'in_channels', + 3) == 3, 'in_channels must be 3 whem pretrained is True' + model.load_state_dict( + model_zoo.load_url(model_urls['resnet152']), strict=False) + return model + + +if __name__ == '__main__': + + x = paddle.zeros([2, 3, 640, 640]) + net = resnet50(pretrained=True) + y = net(x) + for u in y: + print(u.shape) + + print(net.out_channels) diff --git a/benchmark/PaddleOCR_DBNet/models/basic.py b/benchmark/PaddleOCR_DBNet/models/basic.py new file mode 100644 index 00000000..f661878d --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/models/basic.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# @Time : 2019/12/6 11:19 +# @Author : zhoujun +from paddle import nn + + +class ConvBnRelu(nn.Layer): + def __init__(self, + in_channels, + out_channels, + kernel_size, + stride=1, + padding=0, + dilation=1, + groups=1, + bias=True, + padding_mode='zeros', + inplace=True): + super().__init__() + self.conv = nn.Conv2D( + in_channels=in_channels, + out_channels=out_channels, + kernel_size=kernel_size, + stride=stride, + padding=padding, + dilation=dilation, + groups=groups, + bias_attr=bias, + padding_mode=padding_mode) + self.bn = nn.BatchNorm2D(out_channels) + self.relu = nn.ReLU() + + def forward(self, x): + x = self.conv(x) + x = self.bn(x) + x = self.relu(x) + return x diff --git a/benchmark/PaddleOCR_DBNet/models/head/DBHead.py b/benchmark/PaddleOCR_DBNet/models/head/DBHead.py new file mode 100644 index 00000000..29277cec --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/models/head/DBHead.py @@ -0,0 +1,138 @@ +# -*- coding: utf-8 -*- +# @Time : 2019/12/4 14:54 +# @Author : zhoujun +import paddle +from paddle import nn, ParamAttr + + +class DBHead(nn.Layer): + def __init__(self, in_channels, out_channels, k=50): + super().__init__() + self.k = k + self.binarize = nn.Sequential( + nn.Conv2D( + in_channels, + in_channels // 4, + 3, + padding=1, + weight_attr=ParamAttr( + initializer=nn.initializer.KaimingNormal())), + nn.BatchNorm2D( + in_channels // 4, + weight_attr=ParamAttr(initializer=nn.initializer.Constant(1)), + bias_attr=ParamAttr(initializer=nn.initializer.Constant(1e-4))), + nn.ReLU(), + nn.Conv2DTranspose( + in_channels // 4, + in_channels // 4, + 2, + 2, + weight_attr=ParamAttr( + initializer=nn.initializer.KaimingNormal())), + nn.BatchNorm2D( + in_channels // 4, + weight_attr=ParamAttr(initializer=nn.initializer.Constant(1)), + bias_attr=ParamAttr(initializer=nn.initializer.Constant(1e-4))), + nn.ReLU(), + nn.Conv2DTranspose( + in_channels // 4, + 1, + 2, + 2, + weight_attr=nn.initializer.KaimingNormal()), + nn.Sigmoid()) + + self.thresh = self._init_thresh(in_channels) + + def forward(self, x): + shrink_maps = self.binarize(x) + threshold_maps = self.thresh(x) + if self.training: + binary_maps = self.step_function(shrink_maps, threshold_maps) + y = paddle.concat( + (shrink_maps, threshold_maps, binary_maps), axis=1) + else: + y = paddle.concat((shrink_maps, threshold_maps), axis=1) + return y + + def _init_thresh(self, + inner_channels, + serial=False, + smooth=False, + bias=False): + in_channels = inner_channels + if serial: + in_channels += 1 + self.thresh = nn.Sequential( + nn.Conv2D( + in_channels, + inner_channels // 4, + 3, + padding=1, + bias_attr=bias, + weight_attr=ParamAttr( + initializer=nn.initializer.KaimingNormal())), + nn.BatchNorm2D( + inner_channels // 4, + weight_attr=ParamAttr(initializer=nn.initializer.Constant(1)), + bias_attr=ParamAttr(initializer=nn.initializer.Constant(1e-4))), + nn.ReLU(), + self._init_upsample( + inner_channels // 4, + inner_channels // 4, + smooth=smooth, + bias=bias), + nn.BatchNorm2D( + inner_channels // 4, + weight_attr=ParamAttr(initializer=nn.initializer.Constant(1)), + bias_attr=ParamAttr(initializer=nn.initializer.Constant(1e-4))), + nn.ReLU(), + self._init_upsample( + inner_channels // 4, 1, smooth=smooth, bias=bias), + nn.Sigmoid()) + return self.thresh + + def _init_upsample(self, + in_channels, + out_channels, + smooth=False, + bias=False): + if smooth: + inter_out_channels = out_channels + if out_channels == 1: + inter_out_channels = in_channels + module_list = [ + nn.Upsample( + scale_factor=2, mode='nearest'), nn.Conv2D( + in_channels, + inter_out_channels, + 3, + 1, + 1, + bias_attr=bias, + weight_attr=ParamAttr( + initializer=nn.initializer.KaimingNormal())) + ] + if out_channels == 1: + module_list.append( + nn.Conv2D( + in_channels, + out_channels, + kernel_size=1, + stride=1, + padding=1, + bias_attr=True, + weight_attr=ParamAttr( + initializer=nn.initializer.KaimingNormal()))) + return nn.Sequential(module_list) + else: + return nn.Conv2DTranspose( + in_channels, + out_channels, + 2, + 2, + weight_attr=ParamAttr( + initializer=nn.initializer.KaimingNormal())) + + def step_function(self, x, y): + return paddle.reciprocal(1 + paddle.exp(-self.k * (x - y))) diff --git a/benchmark/PaddleOCR_DBNet/models/head/__init__.py b/benchmark/PaddleOCR_DBNet/models/head/__init__.py new file mode 100644 index 00000000..5610c697 --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/models/head/__init__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# @Time : 2020/6/5 11:35 +# @Author : zhoujun +from .DBHead import DBHead + +__all__ = ['build_head'] +support_head = ['DBHead'] + + +def build_head(head_name, **kwargs): + assert head_name in support_head, f'all support head is {support_head}' + head = eval(head_name)(**kwargs) + return head \ No newline at end of file diff --git a/benchmark/PaddleOCR_DBNet/models/losses/DB_loss.py b/benchmark/PaddleOCR_DBNet/models/losses/DB_loss.py new file mode 100644 index 00000000..74d240c1 --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/models/losses/DB_loss.py @@ -0,0 +1,49 @@ +import paddle +from models.losses.basic_loss import BalanceCrossEntropyLoss, MaskL1Loss, DiceLoss + + +class DBLoss(paddle.nn.Layer): + def __init__(self, + alpha=1.0, + beta=10, + ohem_ratio=3, + reduction='mean', + eps=1e-06): + """ + Implement PSE Loss. + :param alpha: binary_map loss 前面的系数 + :param beta: threshold_map loss 前面的系数 + :param ohem_ratio: OHEM的比例 + :param reduction: 'mean' or 'sum'对 batch里的loss 算均值或求和 + """ + super().__init__() + assert reduction in ['mean', 'sum'], " reduction must in ['mean','sum']" + self.alpha = alpha + self.beta = beta + self.bce_loss = BalanceCrossEntropyLoss(negative_ratio=ohem_ratio) + self.dice_loss = DiceLoss(eps=eps) + self.l1_loss = MaskL1Loss(eps=eps) + self.ohem_ratio = ohem_ratio + self.reduction = reduction + + def forward(self, pred, batch): + shrink_maps = pred[:, 0, :, :] + threshold_maps = pred[:, 1, :, :] + binary_maps = pred[:, 2, :, :] + loss_shrink_maps = self.bce_loss(shrink_maps, batch['shrink_map'], + batch['shrink_mask']) + loss_threshold_maps = self.l1_loss( + threshold_maps, batch['threshold_map'], batch['threshold_mask']) + metrics = dict( + loss_shrink_maps=loss_shrink_maps, + loss_threshold_maps=loss_threshold_maps) + if pred.shape[1] > 2: + loss_binary_maps = self.dice_loss(binary_maps, batch['shrink_map'], + batch['shrink_mask']) + metrics['loss_binary_maps'] = loss_binary_maps + loss_all = (self.alpha * loss_shrink_maps + self.beta * + loss_threshold_maps + loss_binary_maps) + metrics['loss'] = loss_all + else: + metrics['loss'] = loss_shrink_maps + return metrics diff --git a/benchmark/PaddleOCR_DBNet/models/losses/__init__.py b/benchmark/PaddleOCR_DBNet/models/losses/__init__.py new file mode 100644 index 00000000..9dc0f103 --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/models/losses/__init__.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# @Time : 2020/6/5 11:36 +# @Author : zhoujun +import copy +from .DB_loss import DBLoss + +__all__ = ['build_loss'] +support_loss = ['DBLoss'] + + +def build_loss(config): + copy_config = copy.deepcopy(config) + loss_type = copy_config.pop('type') + assert loss_type in support_loss, f'all support loss is {support_loss}' + criterion = eval(loss_type)(**copy_config) + return criterion diff --git a/benchmark/PaddleOCR_DBNet/models/losses/basic_loss.py b/benchmark/PaddleOCR_DBNet/models/losses/basic_loss.py new file mode 100644 index 00000000..8e68cb17 --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/models/losses/basic_loss.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +# @Time : 2019/12/4 14:39 +# @Author : zhoujun +import paddle +import paddle.nn as nn + + +class BalanceCrossEntropyLoss(nn.Layer): + ''' + Balanced cross entropy loss. + Shape: + - Input: :math:`(N, 1, H, W)` + - GT: :math:`(N, 1, H, W)`, same shape as the input + - Mask: :math:`(N, H, W)`, same spatial shape as the input + - Output: scalar. + + ''' + + def __init__(self, negative_ratio=3.0, eps=1e-6): + super(BalanceCrossEntropyLoss, self).__init__() + self.negative_ratio = negative_ratio + self.eps = eps + + def forward(self, + pred: paddle.Tensor, + gt: paddle.Tensor, + mask: paddle.Tensor, + return_origin=False): + ''' + Args: + pred: shape :math:`(N, 1, H, W)`, the prediction of network + gt: shape :math:`(N, 1, H, W)`, the target + mask: shape :math:`(N, H, W)`, the mask indicates positive regions + ''' + positive = (gt * mask) + negative = ((1 - gt) * mask) + positive_count = int(positive.sum()) + negative_count = min( + int(negative.sum()), int(positive_count * self.negative_ratio)) + loss = nn.functional.binary_cross_entropy(pred, gt, reduction='none') + positive_loss = loss * positive + negative_loss = loss * negative + negative_loss, _ = negative_loss.reshape([-1]).topk(negative_count) + + balance_loss = (positive_loss.sum() + negative_loss.sum()) / ( + positive_count + negative_count + self.eps) + + if return_origin: + return balance_loss, loss + return balance_loss + + +class DiceLoss(nn.Layer): + ''' + Loss function from https://arxiv.org/abs/1707.03237, + where iou computation is introduced heatmap manner to measure the + diversity bwtween tow heatmaps. + ''' + + def __init__(self, eps=1e-6): + super(DiceLoss, self).__init__() + self.eps = eps + + def forward(self, pred: paddle.Tensor, gt, mask, weights=None): + ''' + pred: one or two heatmaps of shape (N, 1, H, W), + the losses of tow heatmaps are added together. + gt: (N, 1, H, W) + mask: (N, H, W) + ''' + return self._compute(pred, gt, mask, weights) + + def _compute(self, pred, gt, mask, weights): + if len(pred.shape) == 4: + pred = pred[:, 0, :, :] + gt = gt[:, 0, :, :] + assert pred.shape == gt.shape + assert pred.shape == mask.shape + if weights is not None: + assert weights.shape == mask.shape + mask = weights * mask + intersection = (pred * gt * mask).sum() + + union = (pred * mask).sum() + (gt * mask).sum() + self.eps + loss = 1 - 2.0 * intersection / union + assert loss <= 1 + return loss + + +class MaskL1Loss(nn.Layer): + def __init__(self, eps=1e-6): + super(MaskL1Loss, self).__init__() + self.eps = eps + + def forward(self, pred: paddle.Tensor, gt, mask): + loss = (paddle.abs(pred - gt) * mask).sum() / (mask.sum() + self.eps) + return loss diff --git a/benchmark/PaddleOCR_DBNet/models/model.py b/benchmark/PaddleOCR_DBNet/models/model.py new file mode 100644 index 00000000..ee24ff5b --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/models/model.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# @Time : 2019/8/23 21:57 +# @Author : zhoujun +from addict import Dict +from paddle import nn +import paddle.nn.functional as F + +from models.backbone import build_backbone +from models.neck import build_neck +from models.head import build_head + + +class Model(nn.Layer): + def __init__(self, model_config: dict): + """ + PANnet + :param model_config: 模型配置 + """ + super().__init__() + model_config = Dict(model_config) + backbone_type = model_config.backbone.pop('type') + neck_type = model_config.neck.pop('type') + head_type = model_config.head.pop('type') + self.backbone = build_backbone(backbone_type, **model_config.backbone) + self.neck = build_neck( + neck_type, + in_channels=self.backbone.out_channels, + **model_config.neck) + self.head = build_head( + head_type, in_channels=self.neck.out_channels, **model_config.head) + self.name = f'{backbone_type}_{neck_type}_{head_type}' + + def forward(self, x): + _, _, H, W = x.shape + backbone_out = self.backbone(x) + neck_out = self.neck(backbone_out) + y = self.head(neck_out) + y = F.interpolate(y, size=(H, W), mode='bilinear', align_corners=True) + return y diff --git a/benchmark/PaddleOCR_DBNet/models/neck/FPN.py b/benchmark/PaddleOCR_DBNet/models/neck/FPN.py new file mode 100644 index 00000000..53a3fa4b --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/models/neck/FPN.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +# @Time : 2019/9/13 10:29 +# @Author : zhoujun +import paddle +import paddle.nn.functional as F +from paddle import nn + +from models.basic import ConvBnRelu + + +class FPN(nn.Layer): + def __init__(self, in_channels, inner_channels=256, **kwargs): + """ + :param in_channels: 基础网络输出的维度 + :param kwargs: + """ + super().__init__() + inplace = True + self.conv_out = inner_channels + inner_channels = inner_channels // 4 + # reduce layers + self.reduce_conv_c2 = ConvBnRelu( + in_channels[0], inner_channels, kernel_size=1, inplace=inplace) + self.reduce_conv_c3 = ConvBnRelu( + in_channels[1], inner_channels, kernel_size=1, inplace=inplace) + self.reduce_conv_c4 = ConvBnRelu( + in_channels[2], inner_channels, kernel_size=1, inplace=inplace) + self.reduce_conv_c5 = ConvBnRelu( + in_channels[3], inner_channels, kernel_size=1, inplace=inplace) + # Smooth layers + self.smooth_p4 = ConvBnRelu( + inner_channels, + inner_channels, + kernel_size=3, + padding=1, + inplace=inplace) + self.smooth_p3 = ConvBnRelu( + inner_channels, + inner_channels, + kernel_size=3, + padding=1, + inplace=inplace) + self.smooth_p2 = ConvBnRelu( + inner_channels, + inner_channels, + kernel_size=3, + padding=1, + inplace=inplace) + + self.conv = nn.Sequential( + nn.Conv2D( + self.conv_out, + self.conv_out, + kernel_size=3, + padding=1, + stride=1), + nn.BatchNorm2D(self.conv_out), + nn.ReLU()) + self.out_channels = self.conv_out + + def forward(self, x): + c2, c3, c4, c5 = x + # Top-down + p5 = self.reduce_conv_c5(c5) + p4 = self._upsample_add(p5, self.reduce_conv_c4(c4)) + p4 = self.smooth_p4(p4) + p3 = self._upsample_add(p4, self.reduce_conv_c3(c3)) + p3 = self.smooth_p3(p3) + p2 = self._upsample_add(p3, self.reduce_conv_c2(c2)) + p2 = self.smooth_p2(p2) + + x = self._upsample_cat(p2, p3, p4, p5) + x = self.conv(x) + return x + + def _upsample_add(self, x, y): + return F.interpolate(x, size=y.shape[2:]) + y + + def _upsample_cat(self, p2, p3, p4, p5): + h, w = p2.shape[2:] + p3 = F.interpolate(p3, size=(h, w)) + p4 = F.interpolate(p4, size=(h, w)) + p5 = F.interpolate(p5, size=(h, w)) + return paddle.concat([p2, p3, p4, p5], axis=1) diff --git a/benchmark/PaddleOCR_DBNet/models/neck/__init__.py b/benchmark/PaddleOCR_DBNet/models/neck/__init__.py new file mode 100644 index 00000000..76553413 --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/models/neck/__init__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# @Time : 2020/6/5 11:34 +# @Author : zhoujun +from .FPN import FPN + +__all__ = ['build_neck'] +support_neck = ['FPN'] + + +def build_neck(neck_name, **kwargs): + assert neck_name in support_neck, f'all support neck is {support_neck}' + neck = eval(neck_name)(**kwargs) + return neck diff --git a/benchmark/PaddleOCR_DBNet/multi_gpu_train.sh b/benchmark/PaddleOCR_DBNet/multi_gpu_train.sh new file mode 100644 index 00000000..b49a73f1 --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/multi_gpu_train.sh @@ -0,0 +1,2 @@ +# export NCCL_P2P_DISABLE=1 +CUDA_VISIBLE_DEVICES=0,1,2,3 python3 -m paddle.distributed.launch tools/train.py --config_file "config/icdar2015_resnet50_FPN_DBhead_polyLR.yaml" \ No newline at end of file diff --git a/benchmark/PaddleOCR_DBNet/post_processing/__init__.py b/benchmark/PaddleOCR_DBNet/post_processing/__init__.py new file mode 100644 index 00000000..2f8e4322 --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/post_processing/__init__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# @Time : 2019/12/5 15:17 +# @Author : zhoujun + +from .seg_detector_representer import SegDetectorRepresenter + + +def get_post_processing(config): + try: + cls = eval(config['type'])(**config['args']) + return cls + except: + return None \ No newline at end of file diff --git a/benchmark/PaddleOCR_DBNet/post_processing/seg_detector_representer.py b/benchmark/PaddleOCR_DBNet/post_processing/seg_detector_representer.py new file mode 100644 index 00000000..f1273dcf --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/post_processing/seg_detector_representer.py @@ -0,0 +1,192 @@ +import cv2 +import numpy as np +import pyclipper +import paddle +from shapely.geometry import Polygon + + +class SegDetectorRepresenter(): + def __init__(self, + thresh=0.3, + box_thresh=0.7, + max_candidates=1000, + unclip_ratio=1.5): + self.min_size = 3 + self.thresh = thresh + self.box_thresh = box_thresh + self.max_candidates = max_candidates + self.unclip_ratio = unclip_ratio + + def __call__(self, batch, pred, is_output_polygon=False): + ''' + batch: (image, polygons, ignore_tags + batch: a dict produced by dataloaders. + image: tensor of shape (N, C, H, W). + polygons: tensor of shape (N, K, 4, 2), the polygons of objective regions. + ignore_tags: tensor of shape (N, K), indicates whether a region is ignorable or not. + shape: the original shape of images. + filename: the original filenames of images. + pred: + binary: text region segmentation map, with shape (N, H, W) + thresh: [if exists] thresh hold prediction with shape (N, H, W) + thresh_binary: [if exists] binarized with threshhold, (N, H, W) + ''' + if isinstance(pred, paddle.Tensor): + pred = pred.numpy() + pred = pred[:, 0, :, :] + segmentation = self.binarize(pred) + boxes_batch = [] + scores_batch = [] + for batch_index in range(pred.shape[0]): + height, width = batch['shape'][batch_index] + if is_output_polygon: + boxes, scores = self.polygons_from_bitmap( + pred[batch_index], segmentation[batch_index], width, height) + else: + boxes, scores = self.boxes_from_bitmap( + pred[batch_index], segmentation[batch_index], width, height) + boxes_batch.append(boxes) + scores_batch.append(scores) + return boxes_batch, scores_batch + + def binarize(self, pred): + return pred > self.thresh + + def polygons_from_bitmap(self, pred, _bitmap, dest_width, dest_height): + ''' + _bitmap: single map with shape (H, W), + whose values are binarized as {0, 1} + ''' + + assert len(_bitmap.shape) == 2 + bitmap = _bitmap # The first channel + height, width = bitmap.shape + boxes = [] + scores = [] + + contours, _ = cv2.findContours((bitmap * 255).astype(np.uint8), + cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE) + + for contour in contours[:self.max_candidates]: + epsilon = 0.005 * cv2.arcLength(contour, True) + approx = cv2.approxPolyDP(contour, epsilon, True) + points = approx.reshape((-1, 2)) + if points.shape[0] < 4: + continue + # _, sside = self.get_mini_boxes(contour) + # if sside < self.min_size: + # continue + score = self.box_score_fast(pred, contour.squeeze(1)) + if self.box_thresh > score: + continue + + if points.shape[0] > 2: + box = self.unclip(points, unclip_ratio=self.unclip_ratio) + if len(box) > 1: + continue + else: + continue + box = box.reshape(-1, 2) + _, sside = self.get_mini_boxes(box.reshape((-1, 1, 2))) + if sside < self.min_size + 2: + continue + + if not isinstance(dest_width, int): + dest_width = dest_width.item() + dest_height = dest_height.item() + + box[:, 0] = np.clip( + np.round(box[:, 0] / width * dest_width), 0, dest_width) + box[:, 1] = np.clip( + np.round(box[:, 1] / height * dest_height), 0, dest_height) + boxes.append(box) + scores.append(score) + return boxes, scores + + def boxes_from_bitmap(self, pred, _bitmap, dest_width, dest_height): + ''' + _bitmap: single map with shape (H, W), + whose values are binarized as {0, 1} + ''' + + assert len(_bitmap.shape) == 2 + bitmap = _bitmap # The first channel + height, width = bitmap.shape + contours, _ = cv2.findContours((bitmap * 255).astype(np.uint8), + cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE) + num_contours = min(len(contours), self.max_candidates) + boxes = np.zeros((num_contours, 4, 2), dtype=np.int16) + scores = np.zeros((num_contours, ), dtype=np.float32) + + for index in range(num_contours): + contour = contours[index].squeeze(1) + points, sside = self.get_mini_boxes(contour) + if sside < self.min_size: + continue + points = np.array(points) + score = self.box_score_fast(pred, contour) + if self.box_thresh > score: + continue + + box = self.unclip( + points, unclip_ratio=self.unclip_ratio).reshape(-1, 1, 2) + box, sside = self.get_mini_boxes(box) + if sside < self.min_size + 2: + continue + box = np.array(box) + if not isinstance(dest_width, int): + dest_width = dest_width.item() + dest_height = dest_height.item() + + box[:, 0] = np.clip( + np.round(box[:, 0] / width * dest_width), 0, dest_width) + box[:, 1] = np.clip( + np.round(box[:, 1] / height * dest_height), 0, dest_height) + boxes[index, :, :] = box.astype(np.int16) + scores[index] = score + return boxes, scores + + def unclip(self, box, unclip_ratio=1.5): + poly = Polygon(box) + distance = poly.area * unclip_ratio / poly.length + offset = pyclipper.PyclipperOffset() + offset.AddPath(box, pyclipper.JT_ROUND, pyclipper.ET_CLOSEDPOLYGON) + expanded = np.array(offset.Execute(distance)) + return expanded + + def get_mini_boxes(self, contour): + bounding_box = cv2.minAreaRect(contour) + points = sorted(list(cv2.boxPoints(bounding_box)), key=lambda x: x[0]) + + index_1, index_2, index_3, index_4 = 0, 1, 2, 3 + if points[1][1] > points[0][1]: + index_1 = 0 + index_4 = 1 + else: + index_1 = 1 + index_4 = 0 + if points[3][1] > points[2][1]: + index_2 = 2 + index_3 = 3 + else: + index_2 = 3 + index_3 = 2 + + box = [ + points[index_1], points[index_2], points[index_3], points[index_4] + ] + return box, min(bounding_box[1]) + + def box_score_fast(self, bitmap, _box): + h, w = bitmap.shape[:2] + box = _box.copy() + xmin = np.clip(np.floor(box[:, 0].min()).astype(np.int), 0, w - 1) + xmax = np.clip(np.ceil(box[:, 0].max()).astype(np.int), 0, w - 1) + ymin = np.clip(np.floor(box[:, 1].min()).astype(np.int), 0, h - 1) + ymax = np.clip(np.ceil(box[:, 1].max()).astype(np.int), 0, h - 1) + + mask = np.zeros((ymax - ymin + 1, xmax - xmin + 1), dtype=np.uint8) + box[:, 0] = box[:, 0] - xmin + box[:, 1] = box[:, 1] - ymin + cv2.fillPoly(mask, box.reshape(1, -1, 2).astype(np.int32), 1) + return cv2.mean(bitmap[ymin:ymax + 1, xmin:xmax + 1], mask)[0] diff --git a/benchmark/PaddleOCR_DBNet/predict.sh b/benchmark/PaddleOCR_DBNet/predict.sh new file mode 100644 index 00000000..37ab1482 --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/predict.sh @@ -0,0 +1 @@ +CUDA_VISIBLE_DEVICES=0 python tools/predict.py --model_path model_best.pth --input_folder ./input --output_folder ./output --thre 0.7 --polygon --show --save_result \ No newline at end of file diff --git a/benchmark/PaddleOCR_DBNet/requirement.txt b/benchmark/PaddleOCR_DBNet/requirement.txt new file mode 100644 index 00000000..191819f3 --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/requirement.txt @@ -0,0 +1,13 @@ +anyconfig +future +imgaug +matplotlib +numpy +opencv-python +Polygon3 +pyclipper +PyYAML +scikit-image +Shapely +tqdm +addict \ No newline at end of file diff --git a/benchmark/PaddleOCR_DBNet/singlel_gpu_train.sh b/benchmark/PaddleOCR_DBNet/singlel_gpu_train.sh new file mode 100644 index 00000000..f8b9f0e8 --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/singlel_gpu_train.sh @@ -0,0 +1 @@ +CUDA_VISIBLE_DEVICES=0 python3 tools/train.py --config_file "config/icdar2015_resnet50_FPN_DBhead_polyLR.yaml" \ No newline at end of file diff --git a/benchmark/PaddleOCR_DBNet/test/README.MD b/benchmark/PaddleOCR_DBNet/test/README.MD new file mode 100644 index 00000000..b43c6e9a --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/test/README.MD @@ -0,0 +1,8 @@ +Place the images that you want to detect here. You better named them as such: +img_10.jpg +img_11.jpg +img_{img_id}.jpg + +For predicting single images, you can change the `img_path` in the `/tools/predict.py` to your image number. + +The result will be saved in the output_folder(default is test/output) you give in predict.sh \ No newline at end of file diff --git a/benchmark/PaddleOCR_DBNet/test_tipc/benchmark_train.sh b/benchmark/PaddleOCR_DBNet/test_tipc/benchmark_train.sh new file mode 100644 index 00000000..725da8b0 --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/test_tipc/benchmark_train.sh @@ -0,0 +1,287 @@ +#!/bin/bash +source test_tipc/common_func.sh + +# run benchmark sh +# Usage: +# bash run_benchmark_train.sh config.txt params +# or +# bash run_benchmark_train.sh config.txt + +function func_parser_params(){ + strs=$1 + IFS="=" + array=(${strs}) + tmp=${array[1]} + echo ${tmp} +} + +function set_dynamic_epoch(){ + string=$1 + num=$2 + _str=${string:1:6} + IFS="C" + arr=(${_str}) + M=${arr[0]} + P=${arr[1]} + ep=`expr $num \* $M \* $P` + echo $ep +} + +function func_sed_params(){ + filename=$1 + line=$2 + param_value=$3 + params=`sed -n "${line}p" $filename` + IFS=":" + array=(${params}) + key=${array[0]} + value=${array[1]} + + new_params="${key}:${param_value}" + IFS=";" + cmd="sed -i '${line}s/.*/${new_params}/' '${filename}'" + eval $cmd +} + +function set_gpu_id(){ + string=$1 + _str=${string:1:6} + IFS="C" + arr=(${_str}) + M=${arr[0]} + P=${arr[1]} + gn=`expr $P - 1` + gpu_num=`expr $gn / $M` + seq=`seq -s "," 0 $gpu_num` + echo $seq +} + +function get_repo_name(){ + IFS=";" + cur_dir=$(pwd) + IFS="/" + arr=(${cur_dir}) + echo ${arr[-1]} +} + +FILENAME=$1 +# copy FILENAME as new +new_filename="./test_tipc/benchmark_train.txt" +cmd=`yes|cp $FILENAME $new_filename` +FILENAME=$new_filename +# MODE must be one of ['benchmark_train'] +MODE=$2 +PARAMS=$3 + +to_static="" +# parse "to_static" options and modify trainer into "to_static_trainer" +if [[ $PARAMS =~ "dynamicTostatic" ]] ;then + to_static="d2sT_" + sed -i 's/trainer:norm_train/trainer:to_static_train/g' $FILENAME + # clear PARAM contents + if [ $PARAMS = "to_static" ] ;then + PARAMS="" + fi +fi +# bash test_tipc/benchmark_train.sh test_tipc/configs/det_mv3_db_v2_0/train_benchmark.txt benchmark_train dynamic_bs8_fp32_DP_N1C8 +# bash test_tipc/benchmark_train.sh test_tipc/configs/det_mv3_db_v2_0/train_benchmark.txt benchmark_train dynamicTostatic_bs8_fp32_DP_N1C8 +# bash test_tipc/benchmark_train.sh test_tipc/configs/det_mv3_db_v2_0/train_benchmark.txt benchmark_train dynamic_bs8_null_DP_N1C1 +IFS=$'\n' +# parser params from train_benchmark.txt +dataline=`cat $FILENAME` +# parser params +IFS=$'\n' +lines=(${dataline}) +model_name=$(func_parser_value "${lines[1]}") +python_name=$(func_parser_value "${lines[2]}") + +# set env +python=${python_name} +export str_tmp=$(echo `pip list|grep paddlepaddle-gpu|awk -F ' ' '{print $2}'`) +export frame_version=${str_tmp%%.post*} +export frame_commit=$(echo `${python} -c "import paddle;print(paddle.version.commit)"`) + +# 获取benchmark_params所在的行数 +line_num=`grep -n "train_benchmark_params" $FILENAME | cut -d ":" -f 1` +# for train log parser +batch_size=$(func_parser_value "${lines[line_num]}") +line_num=`expr $line_num + 1` +fp_items=$(func_parser_value "${lines[line_num]}") +line_num=`expr $line_num + 1` +epoch=$(func_parser_value "${lines[line_num]}") + +line_num=`expr $line_num + 1` +profile_option_key=$(func_parser_key "${lines[line_num]}") +profile_option_params=$(func_parser_value "${lines[line_num]}") +profile_option="${profile_option_key}:${profile_option_params}" + +line_num=`expr $line_num + 1` +flags_value=$(func_parser_value "${lines[line_num]}") +# set flags +IFS=";" +flags_list=(${flags_value}) +for _flag in ${flags_list[*]}; do + cmd="export ${_flag}" + eval $cmd +done + +# set log_name +repo_name=$(get_repo_name ) +SAVE_LOG=${BENCHMARK_LOG_DIR:-$(pwd)} # */benchmark_log +mkdir -p "${SAVE_LOG}/benchmark_log/" +status_log="${SAVE_LOG}/benchmark_log/results.log" + +# The number of lines in which train params can be replaced. +line_python=3 +line_gpuid=4 +line_precision=6 +line_epoch=7 +line_batchsize=9 +line_profile=13 +line_eval_py=24 +line_export_py=30 + +func_sed_params "$FILENAME" "${line_eval_py}" "null" +func_sed_params "$FILENAME" "${line_export_py}" "null" +func_sed_params "$FILENAME" "${line_python}" "$python" + +# if params +if [ ! -n "$PARAMS" ] ;then + # PARAMS input is not a word. + IFS="|" + batch_size_list=(${batch_size}) + fp_items_list=(${fp_items}) + device_num_list=(N1C4) + run_mode="DP" +elif [[ ${PARAMS} = "dynamicTostatic" ]];then + IFS="|" + model_type=$PARAMS + batch_size_list=(${batch_size}) + fp_items_list=(${fp_items}) + device_num_list=(N1C4) + run_mode="DP" +else + # parser params from input: modeltype_bs${bs_item}_${fp_item}_${run_mode}_${device_num} + IFS="_" + params_list=(${PARAMS}) + model_type=${params_list[0]} + batch_size=${params_list[1]} + batch_size=`echo ${batch_size} | tr -cd "[0-9]" ` + precision=${params_list[2]} + run_mode=${params_list[3]} + device_num=${params_list[4]} + IFS=";" + + if [ ${precision} = "fp16" ];then + precision="amp" + fi + + epoch=$(set_dynamic_epoch $device_num $epoch) + fp_items_list=($precision) + batch_size_list=($batch_size) + device_num_list=($device_num) +fi + +IFS="|" +for batch_size in ${batch_size_list[*]}; do + for train_precision in ${fp_items_list[*]}; do + for device_num in ${device_num_list[*]}; do + # sed batchsize and precision + if [ ${train_precision} = "amp" ];then + precision="fp16" + else + precision="fp32" + fi + + func_sed_params "$FILENAME" "${line_precision}" "$train_precision" + func_sed_params "$FILENAME" "${line_batchsize}" "$MODE=$batch_size" + func_sed_params "$FILENAME" "${line_epoch}" "$MODE=$epoch" + gpu_id=$(set_gpu_id $device_num) + + if [ ${#gpu_id} -le 1 ];then + log_path="$SAVE_LOG/profiling_log" + mkdir -p $log_path + log_name="${repo_name}_${model_name}_bs${batch_size}_${precision}_${run_mode}_${device_num}_${to_static}profiling" + func_sed_params "$FILENAME" "${line_gpuid}" "0" # sed used gpu_id + # set profile_option params + tmp=`sed -i "${line_profile}s/.*/${profile_option}/" "${FILENAME}"` + + # run test_train_inference_python.sh + cmd="bash test_tipc/test_train_inference_python.sh ${FILENAME} benchmark_train > ${log_path}/${log_name} 2>&1 " + echo $cmd + eval $cmd + eval "cat ${log_path}/${log_name}" + + # without profile + log_path="$SAVE_LOG/train_log" + speed_log_path="$SAVE_LOG/index" + mkdir -p $log_path + mkdir -p $speed_log_path + log_name="${repo_name}_${model_name}_bs${batch_size}_${precision}_${run_mode}_${device_num}_${to_static}log" + speed_log_name="${repo_name}_${model_name}_bs${batch_size}_${precision}_${run_mode}_${device_num}_${to_static}speed" + func_sed_params "$FILENAME" "${line_profile}" "null" # sed profile_id as null + cmd="bash test_tipc/test_train_inference_python.sh ${FILENAME} benchmark_train > ${log_path}/${log_name} 2>&1 " + echo $cmd + job_bt=`date '+%Y%m%d%H%M%S'` + eval $cmd + job_et=`date '+%Y%m%d%H%M%S'` + export model_run_time=$((${job_et}-${job_bt})) + eval "cat ${log_path}/${log_name}" + + # parser log + _model_name="${model_name}_bs${batch_size}_${precision}_${run_mode}" + cmd="${python} ${BENCHMARK_ROOT}/scripts/analysis.py --filename ${log_path}/${log_name} \ + --speed_log_file '${speed_log_path}/${speed_log_name}' \ + --model_name ${_model_name} \ + --base_batch_size ${batch_size} \ + --run_mode ${run_mode} \ + --fp_item ${precision} \ + --keyword ips: \ + --skip_steps 2 \ + --device_num ${device_num} \ + --speed_unit samples/s \ + --convergence_key loss: " + echo $cmd + eval $cmd + last_status=${PIPESTATUS[0]} + status_check $last_status "${cmd}" "${status_log}" + else + IFS=";" + unset_env=`unset CUDA_VISIBLE_DEVICES` + log_path="$SAVE_LOG/train_log" + speed_log_path="$SAVE_LOG/index" + mkdir -p $log_path + mkdir -p $speed_log_path + log_name="${repo_name}_${model_name}_bs${batch_size}_${precision}_${run_mode}_${device_num}_${to_static}log" + speed_log_name="${repo_name}_${model_name}_bs${batch_size}_${precision}_${run_mode}_${device_num}_${to_static}speed" + func_sed_params "$FILENAME" "${line_gpuid}" "$gpu_id" # sed used gpu_id + func_sed_params "$FILENAME" "${line_profile}" "null" # sed --profile_option as null + cmd="bash test_tipc/test_train_inference_python.sh ${FILENAME} benchmark_train > ${log_path}/${log_name} 2>&1 " + echo $cmd + job_bt=`date '+%Y%m%d%H%M%S'` + eval $cmd + job_et=`date '+%Y%m%d%H%M%S'` + export model_run_time=$((${job_et}-${job_bt})) + eval "cat ${log_path}/${log_name}" + # parser log + _model_name="${model_name}_bs${batch_size}_${precision}_${run_mode}" + + cmd="${python} ${BENCHMARK_ROOT}/scripts/analysis.py --filename ${log_path}/${log_name} \ + --speed_log_file '${speed_log_path}/${speed_log_name}' \ + --model_name ${_model_name} \ + --base_batch_size ${batch_size} \ + --run_mode ${run_mode} \ + --fp_item ${precision} \ + --keyword ips: \ + --skip_steps 2 \ + --device_num ${device_num} \ + --speed_unit images/s \ + --convergence_key loss: " + echo $cmd + eval $cmd + last_status=${PIPESTATUS[0]} + status_check $last_status "${cmd}" "${status_log}" + fi + done + done +done diff --git a/benchmark/PaddleOCR_DBNet/test_tipc/common_func.sh b/benchmark/PaddleOCR_DBNet/test_tipc/common_func.sh new file mode 100644 index 00000000..c123d3cf --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/test_tipc/common_func.sh @@ -0,0 +1,67 @@ +#!/bin/bash + +function func_parser_key(){ + strs=$1 + IFS=":" + array=(${strs}) + tmp=${array[0]} + echo ${tmp} +} + +function func_parser_value(){ + strs=$1 + IFS=":" + array=(${strs}) + tmp=${array[1]} + echo ${tmp} +} + +function func_set_params(){ + key=$1 + value=$2 + if [ ${key}x = "null"x ];then + echo " " + elif [[ ${value} = "null" ]] || [[ ${value} = " " ]] || [ ${#value} -le 0 ];then + echo " " + else + echo "${key}=${value}" + fi +} + +function func_parser_params(){ + strs=$1 + MODE=$2 + IFS=":" + array=(${strs}) + key=${array[0]} + tmp=${array[1]} + IFS="|" + res="" + for _params in ${tmp[*]}; do + IFS="=" + array=(${_params}) + mode=${array[0]} + value=${array[1]} + if [[ ${mode} = ${MODE} ]]; then + IFS="|" + #echo $(func_set_params "${mode}" "${value}") + echo $value + break + fi + IFS="|" + done + echo ${res} +} + +function status_check(){ + last_status=$1 # the exit code + run_command=$2 + run_log=$3 + model_name=$4 + log_path=$5 + if [ $last_status -eq 0 ]; then + echo -e "\033[33m Run successfully with command - ${model_name} - ${run_command} - ${log_path} \033[0m" | tee -a ${run_log} + else + echo -e "\033[33m Run failed with command - ${model_name} - ${run_command} - ${log_path} \033[0m" | tee -a ${run_log} + fi +} \ No newline at end of file diff --git a/benchmark/PaddleOCR_DBNet/test_tipc/configs/det_res50_db/train_infer_python.txt b/benchmark/PaddleOCR_DBNet/test_tipc/configs/det_res50_db/train_infer_python.txt new file mode 100644 index 00000000..20bb49fe --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/test_tipc/configs/det_res50_db/train_infer_python.txt @@ -0,0 +1,59 @@ +===========================train_params=========================== +model_name:det_res50_db +python:python3.7 +gpu_list:0|0,1 +trainer.use_gpu:True|True +amp:null +trainer.epochs:lite_train_lite_infer=1|whole_train_whole_infer=300 +trainer.output_dir:./output/ +dataset.train.loader.batch_size:lite_train_lite_infer=8|whole_train_lite_infer=8 +trainer.finetune_checkpoint:null +train_model_name:checkpoint/model_latest.pth +train_infer_img_dir:imgs/paper/db.jpg +null:null +## +trainer:norm_train +norm_train:tools/train.py --config_file config/icdar2015_resnet50_FPN_DBhead_polyLR.yaml -o trainer.log_iter=1 trainer.enable_eval=False dataset.train.loader.shuffle=false arch.backbone.pretrained=False +quant_export:null +fpgm_export:null +distill_train:null +to_static_train:trainer.to_static=true +null:null +## +===========================eval_params=========================== +eval:null +null:null +## +===========================infer_params=========================== +trainer.output_dir:./output/ +trainer.resume_checkpoint: +norm_export:tools/export_model.py --config_file config/icdar2015_resnet50_FPN_DBhead_polyLR.yaml -o +quant_export:null +fpgm_export:null +distill_export:null +export1:null +export2:null +## +train_model:./inference/det_r50_vd_db_v2.0_train/best_accuracy +infer_export:tools/export_model.py --config_file config/icdar2015_resnet50_FPN_DBhead_polyLR.yaml -o +infer_quant:False +inference:tools/infer.py +--use_gpu:True|False +--enable_mkldnn:False +--cpu_threads:6 +--batch_size:1 +--use_tensorrt:False +--precision:fp32 +--model_dir: +--img_path:imgs/paper/db.jpg +--save_log_path:null +--benchmark:True +null:null +===========================infer_benchmark_params========================== +random_infer_input:[{float32,[3,640,640]}];[{float32,[3,960,960]}] +===========================train_benchmark_params========================== +batch_size:8 +fp_items:fp32|fp16 +epoch:2 +--profiler_options:batch_range=[10,20];state=GPU;tracer_option=Default;profile_path=model.profile +flags:FLAGS_eager_delete_tensor_gb=0.0;FLAGS_fraction_of_gpu_memory_to_use=0.98;FLAGS_conv_workspace_size_limit=4096 \ No newline at end of file diff --git a/benchmark/PaddleOCR_DBNet/test_tipc/prepare.sh b/benchmark/PaddleOCR_DBNet/test_tipc/prepare.sh new file mode 100644 index 00000000..cd8f56fd --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/test_tipc/prepare.sh @@ -0,0 +1,54 @@ +#!/bin/bash +source test_tipc/common_func.sh + +FILENAME=$1 + +# MODE be one of ['lite_train_lite_infer' 'lite_train_whole_infer' 'whole_train_whole_infer', +# 'whole_infer', 'klquant_whole_infer', +# 'cpp_infer', 'serving_infer'] + +MODE=$2 + +dataline=$(cat ${FILENAME}) + +# parser params +IFS=$'\n' +lines=(${dataline}) + +# The training params +model_name=$(func_parser_value "${lines[1]}") + +trainer_list=$(func_parser_value "${lines[14]}") + +if [ ${MODE} = "lite_train_lite_infer" ];then + python_name_list=$(func_parser_value "${lines[2]}") + array=(${python_name_list}) + python_name=${array[0]} + ${python_name} -m pip install -r requirement.txt + if [[ ${model_name} =~ "det_res50_db" ]];then + wget -nc https://paddle-wheel.bj.bcebos.com/benchmark/resnet50-19c8e357.pth -O /root/.cache/torch/hub/checkpoints/resnet50-19c8e357.pth + + # 下载数据集并解压 + rm -rf datasets + wget -nc https://paddleocr.bj.bcebos.com/dygraph_v2.0/test/benchmark_train/datasets.tar + tar xf datasets.tar + fi +elif [ ${MODE} = "benchmark_train" ];then + python_name_list=$(func_parser_value "${lines[2]}") + array=(${python_name_list}) + python_name=${array[0]} + ${python_name} -m pip install -r requirement.txt + if [[ ${model_name} =~ "det_res50_db" ]];then + wget -nc https://paddle-wheel.bj.bcebos.com/benchmark/resnet50-19c8e357.pth -O /root/.cache/torch/hub/checkpoints/resnet50-19c8e357.pth + + # 下载数据集并解压 + rm -rf datasets + wget -nc https://paddleocr.bj.bcebos.com/dygraph_v2.0/test/benchmark_train/datasets.tar + tar xf datasets.tar + # expand gt.txt 2 times + # cd ./train_data/icdar2015/text_localization + # for i in `seq 2`;do cp train_icdar2015_label.txt dup$i.txt;done + # cat dup* > train_icdar2015_label.txt && rm -rf dup* + # cd ../../../ + fi +fi \ No newline at end of file diff --git a/benchmark/PaddleOCR_DBNet/test_tipc/test_train_inference_python.sh b/benchmark/PaddleOCR_DBNet/test_tipc/test_train_inference_python.sh new file mode 100644 index 00000000..64e5aca1 --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/test_tipc/test_train_inference_python.sh @@ -0,0 +1,340 @@ +#!/bin/bash +source test_tipc/common_func.sh + +FILENAME=$1 +# MODE be one of ['lite_train_lite_infer' 'lite_train_whole_infer' 'whole_train_whole_infer', 'whole_infer'] +MODE=$2 + +dataline=$(awk 'NR==1, NR==51{print}' $FILENAME) + +# parser params +IFS=$'\n' +lines=(${dataline}) + +# The training params +model_name=$(func_parser_value "${lines[1]}") +python=$(func_parser_value "${lines[2]}") +gpu_list=$(func_parser_value "${lines[3]}") +train_use_gpu_key=$(func_parser_key "${lines[4]}") +train_use_gpu_value=$(func_parser_value "${lines[4]}") +autocast_list=$(func_parser_value "${lines[5]}") +autocast_key=$(func_parser_key "${lines[5]}") +epoch_key=$(func_parser_key "${lines[6]}") +epoch_num=$(func_parser_params "${lines[6]}" "${MODE}") +save_model_key=$(func_parser_key "${lines[7]}") +train_batch_key=$(func_parser_key "${lines[8]}") +train_batch_value=$(func_parser_params "${lines[8]}" "${MODE}") +pretrain_model_key=$(func_parser_key "${lines[9]}") +pretrain_model_value=$(func_parser_value "${lines[9]}") +train_model_name=$(func_parser_value "${lines[10]}") +train_infer_img_dir=$(func_parser_value "${lines[11]}") +train_param_key1=$(func_parser_key "${lines[12]}") +train_param_value1=$(func_parser_value "${lines[12]}") + +trainer_list=$(func_parser_value "${lines[14]}") +trainer_norm=$(func_parser_key "${lines[15]}") +norm_trainer=$(func_parser_value "${lines[15]}") +pact_key=$(func_parser_key "${lines[16]}") +pact_trainer=$(func_parser_value "${lines[16]}") +fpgm_key=$(func_parser_key "${lines[17]}") +fpgm_trainer=$(func_parser_value "${lines[17]}") +distill_key=$(func_parser_key "${lines[18]}") +distill_trainer=$(func_parser_value "${lines[18]}") +to_static_key=$(func_parser_key "${lines[19]}") +to_static_value=$(func_parser_value "${lines[19]}") +trainer_key2=$(func_parser_key "${lines[20]}") +trainer_value2=$(func_parser_value "${lines[20]}") + +eval_py=$(func_parser_value "${lines[23]}") +eval_key1=$(func_parser_key "${lines[24]}") +eval_value1=$(func_parser_value "${lines[24]}") + +save_infer_key=$(func_parser_key "${lines[27]}") +export_weight=$(func_parser_key "${lines[28]}") +norm_export=$(func_parser_value "${lines[29]}") +pact_export=$(func_parser_value "${lines[30]}") +fpgm_export=$(func_parser_value "${lines[31]}") +distill_export=$(func_parser_value "${lines[32]}") +export_key1=$(func_parser_key "${lines[33]}") +export_value1=$(func_parser_value "${lines[33]}") +export_key2=$(func_parser_key "${lines[34]}") +export_value2=$(func_parser_value "${lines[34]}") +inference_dir=$(func_parser_value "${lines[35]}") + +# parser inference model +infer_model_dir_list=$(func_parser_value "${lines[36]}") +infer_export_list=$(func_parser_value "${lines[37]}") +infer_is_quant=$(func_parser_value "${lines[38]}") +# parser inference +inference_py=$(func_parser_value "${lines[39]}") +use_gpu_key=$(func_parser_key "${lines[40]}") +use_gpu_list=$(func_parser_value "${lines[40]}") +use_mkldnn_key=$(func_parser_key "${lines[41]}") +use_mkldnn_list=$(func_parser_value "${lines[41]}") +cpu_threads_key=$(func_parser_key "${lines[42]}") +cpu_threads_list=$(func_parser_value "${lines[42]}") +batch_size_key=$(func_parser_key "${lines[43]}") +batch_size_list=$(func_parser_value "${lines[43]}") +use_trt_key=$(func_parser_key "${lines[44]}") +use_trt_list=$(func_parser_value "${lines[44]}") +precision_key=$(func_parser_key "${lines[45]}") +precision_list=$(func_parser_value "${lines[45]}") +infer_model_key=$(func_parser_key "${lines[46]}") +image_dir_key=$(func_parser_key "${lines[47]}") +infer_img_dir=$(func_parser_value "${lines[47]}") +save_log_key=$(func_parser_key "${lines[48]}") +benchmark_key=$(func_parser_key "${lines[49]}") +benchmark_value=$(func_parser_value "${lines[49]}") +infer_key1=$(func_parser_key "${lines[50]}") +infer_value1=$(func_parser_value "${lines[50]}") + +LOG_PATH="./test_tipc/output/${model_name}/${MODE}" +mkdir -p ${LOG_PATH} +status_log="${LOG_PATH}/results_python.log" + + +function func_inference(){ + IFS='|' + _python=$1 + _script=$2 + _model_dir=$3 + _log_path=$4 + _img_dir=$5 + _flag_quant=$6 + _gpu=$7 + # inference + for use_gpu in ${use_gpu_list[*]}; do + if [ ${use_gpu} = "False" ] || [ ${use_gpu} = "cpu" ]; then + for use_mkldnn in ${use_mkldnn_list[*]}; do + # if [ ${use_mkldnn} = "False" ] && [ ${_flag_quant} = "True" ]; then + # continue + # fi + for threads in ${cpu_threads_list[*]}; do + for batch_size in ${batch_size_list[*]}; do + for precision in ${precision_list[*]}; do + if [ ${use_mkldnn} = "False" ] && [ ${precision} = "fp16" ]; then + continue + fi # skip when enable fp16 but disable mkldnn + if [ ${_flag_quant} = "True" ] && [ ${precision} != "int8" ]; then + continue + fi # skip when quant model inference but precision is not int8 + set_precision=$(func_set_params "${precision_key}" "${precision}") + + _save_log_path="${_log_path}/python_infer_cpu_gpus_${_gpu}_usemkldnn_${use_mkldnn}_threads_${threads}_precision_${precision}_batchsize_${batch_size}.log" + set_infer_data=$(func_set_params "${image_dir_key}" "${_img_dir}") + set_benchmark=$(func_set_params "${benchmark_key}" "${benchmark_value}") + set_batchsize=$(func_set_params "${batch_size_key}" "${batch_size}") + set_mkldnn=$(func_set_params "${use_mkldnn_key}" "${use_mkldnn}") + set_cpu_threads=$(func_set_params "${cpu_threads_key}" "${threads}") + set_model_dir=$(func_set_params "${infer_model_key}" "${_model_dir}") + set_infer_params0=$(func_set_params "${save_log_key}" "${save_log_value}") + set_infer_params1=$(func_set_params "${infer_key1}" "${infer_value1}") + command="${_python} ${_script} ${use_gpu_key}=${use_gpu} ${set_mkldnn} ${set_cpu_threads} ${set_model_dir} ${set_batchsize} ${set_infer_params0} ${set_infer_data} ${set_benchmark} ${set_precision} ${set_infer_params1} > ${_save_log_path} 2>&1 " + eval $command + last_status=${PIPESTATUS[0]} + eval "cat ${_save_log_path}" + status_check $last_status "${command}" "${status_log}" "${model_name}" "${_save_log_path}" + done + done + done + done + elif [ ${use_gpu} = "True" ] || [ ${use_gpu} = "gpu" ]; then + for use_trt in ${use_trt_list[*]}; do + for precision in ${precision_list[*]}; do + if [[ ${_flag_quant} = "False" ]] && [[ ${precision} =~ "int8" ]]; then + continue + fi + if [[ ${precision} =~ "fp16" || ${precision} =~ "int8" ]] && [ ${use_trt} = "False" ]; then + continue + fi + if [[ ${use_trt} = "False" && ${precision} =~ "int8" ]] && [ ${_flag_quant} = "True" ]; then + continue + fi + for batch_size in ${batch_size_list[*]}; do + _save_log_path="${_log_path}/python_infer_gpu_gpus_${_gpu}_usetrt_${use_trt}_precision_${precision}_batchsize_${batch_size}.log" + set_infer_data=$(func_set_params "${image_dir_key}" "${_img_dir}") + set_benchmark=$(func_set_params "${benchmark_key}" "${benchmark_value}") + set_batchsize=$(func_set_params "${batch_size_key}" "${batch_size}") + set_tensorrt=$(func_set_params "${use_trt_key}" "${use_trt}") + set_precision=$(func_set_params "${precision_key}" "${precision}") + set_model_dir=$(func_set_params "${infer_model_key}" "${_model_dir}") + set_infer_params0=$(func_set_params "${save_log_key}" "${save_log_value}") + set_infer_params1=$(func_set_params "${infer_key1}" "${infer_value1}") + command="${_python} ${_script} ${use_gpu_key}=${use_gpu} ${set_tensorrt} ${set_precision} ${set_model_dir} ${set_batchsize} ${set_infer_data} ${set_benchmark} ${set_infer_params1} ${set_infer_params0} > ${_save_log_path} 2>&1 " + eval $command + last_status=${PIPESTATUS[0]} + eval "cat ${_save_log_path}" + status_check $last_status "${command}" "${status_log}" "${model_name}" "${_save_log_path}" + + done + done + done + else + echo "Does not support hardware other than CPU and GPU Currently!" + fi + done +} + +if [ ${MODE} = "whole_infer" ]; then + GPUID=$3 + if [ ${#GPUID} -le 0 ];then + env=" " + else + env="export CUDA_VISIBLE_DEVICES=${GPUID}" + fi + # set CUDA_VISIBLE_DEVICES + eval $env + export Count=0 + gpu=0 + IFS="|" + infer_run_exports=(${infer_export_list}) + infer_quant_flag=(${infer_is_quant}) + for infer_model in ${infer_model_dir_list[*]}; do + # run export + if [ ${infer_run_exports[Count]} != "null" ];then + save_infer_dir="${infer_model}" + set_export_weight=$(func_set_params "${export_weight}" "${infer_model}") + set_save_infer_key=$(func_set_params "${save_infer_key}" "${save_infer_dir}") + export_log_path="${LOG_PATH}_export_${Count}.log" + export_cmd="${python} ${infer_run_exports[Count]} ${set_export_weight} ${set_save_infer_key} > ${export_log_path} 2>&1 " + echo ${infer_run_exports[Count]} + echo $export_cmd + eval $export_cmd + status_export=$? + status_check $status_export "${export_cmd}" "${status_log}" "${model_name}" "${export_log_path}" + else + save_infer_dir=${infer_model} + fi + #run inference + is_quant=${infer_quant_flag[Count]} + func_inference "${python}" "${inference_py}" "${save_infer_dir}" "${LOG_PATH}" "${infer_img_dir}" ${is_quant} "${gpu}" + Count=$(($Count + 1)) + done +else + IFS="|" + export Count=0 + USE_GPU_KEY=(${train_use_gpu_value}) + for gpu in ${gpu_list[*]}; do + train_use_gpu=${USE_GPU_KEY[Count]} + Count=$(($Count + 1)) + ips="" + if [ ${gpu} = "-1" ];then + env="" + elif [ ${#gpu} -le 1 ];then + env="export CUDA_VISIBLE_DEVICES=${gpu}" + elif [ ${#gpu} -le 15 ];then + IFS="," + array=(${gpu}) + env="export CUDA_VISIBLE_DEVICES=${array[0]}" + IFS="|" + else + IFS=";" + array=(${gpu}) + ips=${array[0]} + gpu=${array[1]} + IFS="|" + env=" " + fi + for autocast in ${autocast_list[*]}; do + if [ ${autocast} = "amp" ]; then + set_amp_config="amp.scale_loss=1024.0 amp.use_dynamic_loss_scaling=True amp.amp_level=O2" + else + set_amp_config="amp=None" + fi + for trainer in ${trainer_list[*]}; do + flag_quant=False + if [ ${trainer} = ${pact_key} ]; then + run_train=${pact_trainer} + run_export=${pact_export} + flag_quant=True + elif [ ${trainer} = "${fpgm_key}" ]; then + run_train=${fpgm_trainer} + run_export=${fpgm_export} + elif [ ${trainer} = "${distill_key}" ]; then + run_train=${distill_trainer} + run_export=${distill_export} + elif [ ${trainer} = "${to_static_key}" ]; then + run_train="${norm_trainer} ${to_static_value}" + run_export=${norm_export} + elif [[ ${trainer} = ${trainer_key2} ]]; then + run_train=${trainer_value2} + run_export=${export_value2} + else + run_train=${norm_trainer} + run_export=${norm_export} + fi + + if [ ${run_train} = "null" ]; then + continue + fi + + set_epoch=$(func_set_params "${epoch_key}" "${epoch_num}") + set_pretrain=$(func_set_params "${pretrain_model_key}" "${pretrain_model_value}") + set_batchsize=$(func_set_params "${train_batch_key}" "${train_batch_value}") + set_train_params1=$(func_set_params "${train_param_key1}" "${train_param_value1}") + set_use_gpu=$(func_set_params "${train_use_gpu_key}" "${train_use_gpu}") + # if length of ips >= 15, then it is seen as multi-machine + # 15 is the min length of ips info for multi-machine: 0.0.0.0,0.0.0.0 + if [ ${#ips} -le 15 ];then + save_log="${LOG_PATH}/${trainer}_gpus_${gpu}_autocast_${autocast}" + nodes=1 + else + IFS="," + ips_array=(${ips}) + IFS="|" + nodes=${#ips_array[@]} + save_log="${LOG_PATH}/${trainer}_gpus_${gpu}_autocast_${autocast}_nodes_${nodes}" + fi + + + set_save_model=$(func_set_params "${save_model_key}" "${save_log}") + if [ ${#gpu} -le 2 ];then # train with cpu or single gpu + cmd="${python} ${run_train} ${set_use_gpu} ${set_save_model} ${set_epoch} ${set_pretrain} ${set_batchsize} ${set_amp_config} ${set_train_params1}" + elif [ ${#ips} -le 15 ];then # train with multi-gpu + cmd="${python} -m paddle.distributed.launch --gpus=${gpu} ${run_train} ${set_use_gpu} ${set_save_model} ${set_epoch} ${set_pretrain} ${set_batchsize} ${set_amp_config} ${set_train_params1}" + else # train with multi-machine + cmd="${python} -m paddle.distributed.launch --ips=${ips} --gpus=${gpu} ${run_train} ${set_use_gpu} ${set_save_model} ${set_pretrain} ${set_epoch} ${set_batchsize} ${set_amp_config} ${set_train_params1}" + fi + # run train + eval $cmd + eval "cat ${save_log}/train.log >> ${save_log}.log" + status_check $? "${cmd}" "${status_log}" "${model_name}" "${save_log}.log" + + set_eval_pretrain=$(func_set_params "${pretrain_model_key}" "${save_log}/${train_model_name}") + + # run eval + if [ ${eval_py} != "null" ]; then + eval ${env} + set_eval_params1=$(func_set_params "${eval_key1}" "${eval_value1}") + eval_log_path="${LOG_PATH}/${trainer}_gpus_${gpu}_autocast_${autocast}_nodes_${nodes}_eval.log" + eval_cmd="${python} ${eval_py} ${set_eval_pretrain} ${set_use_gpu} ${set_eval_params1} > ${eval_log_path} 2>&1 " + eval $eval_cmd + status_check $? "${eval_cmd}" "${status_log}" "${model_name}" "${eval_log_path}" + fi + # run export model + if [ ${run_export} != "null" ]; then + # run export model + save_infer_path="${save_log}" + export_log_path="${LOG_PATH}/${trainer}_gpus_${gpu}_autocast_${autocast}_nodes_${nodes}_export.log" + set_export_weight=$(func_set_params "${export_weight}" "${save_log}/${train_model_name}") + set_save_infer_key=$(func_set_params "${save_infer_key}" "${save_infer_path}") + export_cmd="${python} ${run_export} ${set_export_weight} ${set_save_infer_key} > ${export_log_path} 2>&1 " + eval $export_cmd + status_check $? "${export_cmd}" "${status_log}" "${model_name}" "${export_log_path}" + + #run inference + eval $env + save_infer_path="${save_log}" + if [[ ${inference_dir} != "null" ]] && [[ ${inference_dir} != '##' ]]; then + infer_model_dir="${save_infer_path}/${inference_dir}" + else + infer_model_dir=${save_infer_path} + fi + func_inference "${python}" "${inference_py}" "${infer_model_dir}" "${LOG_PATH}" "${train_infer_img_dir}" "${flag_quant}" "${gpu}" + + eval "unset CUDA_VISIBLE_DEVICES" + fi + done # done with: for trainer in ${trainer_list[*]}; do + done # done with: for autocast in ${autocast_list[*]}; do + done # done with: for gpu in ${gpu_list[*]}; do +fi # end if [ ${MODE} = "infer" ]; then \ No newline at end of file diff --git a/benchmark/PaddleOCR_DBNet/tools/__init__.py b/benchmark/PaddleOCR_DBNet/tools/__init__.py new file mode 100644 index 00000000..7cbf835d --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/tools/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +# @Time : 2019/12/8 13:14 +# @Author : zhoujun \ No newline at end of file diff --git a/benchmark/PaddleOCR_DBNet/tools/eval.py b/benchmark/PaddleOCR_DBNet/tools/eval.py new file mode 100644 index 00000000..fe514ddc --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/tools/eval.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +# @Time : 2018/6/11 15:54 +# @Author : zhoujun +import os +import sys +import pathlib +__dir__ = pathlib.Path(os.path.abspath(__file__)) +sys.path.append(str(__dir__)) +sys.path.append(str(__dir__.parent.parent)) + +import argparse +import time +import paddle +from tqdm.auto import tqdm + + +class EVAL(): + def __init__(self, model_path, gpu_id=0): + from models import build_model + from data_loader import get_dataloader + from post_processing import get_post_processing + from utils import get_metric + self.gpu_id = gpu_id + if self.gpu_id is not None and isinstance( + self.gpu_id, int) and paddle.device.is_compiled_with_cuda(): + paddle.device.set_device("gpu:{}".format(self.gpu_id)) + else: + paddle.device.set_device("cpu") + checkpoint = paddle.load(model_path) + config = checkpoint['config'] + config['arch']['backbone']['pretrained'] = False + + self.validate_loader = get_dataloader(config['dataset']['validate'], + config['distributed']) + + self.model = build_model(config['arch']) + self.model.set_state_dict(checkpoint['state_dict']) + + self.post_process = get_post_processing(config['post_processing']) + self.metric_cls = get_metric(config['metric']) + + def eval(self): + self.model.eval() + raw_metrics = [] + total_frame = 0.0 + total_time = 0.0 + for i, batch in tqdm( + enumerate(self.validate_loader), + total=len(self.validate_loader), + desc='test model'): + with paddle.no_grad(): + start = time.time() + preds = self.model(batch['img']) + boxes, scores = self.post_process( + batch, + preds, + is_output_polygon=self.metric_cls.is_output_polygon) + total_frame += batch['img'].shape[0] + total_time += time.time() - start + raw_metric = self.metric_cls.validate_measure(batch, + (boxes, scores)) + raw_metrics.append(raw_metric) + metrics = self.metric_cls.gather_measure(raw_metrics) + print('FPS:{}'.format(total_frame / total_time)) + return { + 'recall': metrics['recall'].avg, + 'precision': metrics['precision'].avg, + 'fmeasure': metrics['fmeasure'].avg + } + + +def init_args(): + parser = argparse.ArgumentParser(description='DBNet.paddle') + parser.add_argument( + '--model_path', + required=False, + default='output/DBNet_resnet18_FPN_DBHead/checkpoint/1.pth', + type=str) + args = parser.parse_args() + return args + + +if __name__ == '__main__': + args = init_args() + eval = EVAL(args.model_path) + result = eval.eval() + print(result) diff --git a/benchmark/PaddleOCR_DBNet/tools/export_model.py b/benchmark/PaddleOCR_DBNet/tools/export_model.py new file mode 100644 index 00000000..59a318a1 --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/tools/export_model.py @@ -0,0 +1,57 @@ +import os +import sys + +__dir__ = os.path.dirname(os.path.abspath(__file__)) +sys.path.append(__dir__) +sys.path.insert(0, os.path.abspath(os.path.join(__dir__, ".."))) + +import argparse + +import paddle +from paddle.jit import to_static + +from models import build_model +from utils import Config, ArgsParser + + +def init_args(): + parser = ArgsParser() + args = parser.parse_args() + return args + + +def load_checkpoint(model, checkpoint_path): + """ + load checkpoints + :param checkpoint_path: Checkpoint path to be loaded + """ + checkpoint = paddle.load(checkpoint_path) + model.set_state_dict(checkpoint['state_dict']) + print('load checkpoint from {}'.format(checkpoint_path)) + + +def main(config): + model = build_model(config['arch']) + load_checkpoint(model, config['trainer']['resume_checkpoint']) + model.eval() + + save_path = config["trainer"]["output_dir"] + save_path = os.path.join(save_path, "inference") + infer_shape = [3, -1, -1] + model = to_static( + model, + input_spec=[ + paddle.static.InputSpec( + shape=[None] + infer_shape, dtype="float32") + ]) + + paddle.jit.save(model, save_path) + print("inference model is saved to {}".format(save_path)) + + +if __name__ == "__main__": + args = init_args() + assert os.path.exists(args.config_file) + config = Config(args.config_file) + config.merge_dict(args.opt) + main(config.cfg) diff --git a/benchmark/PaddleOCR_DBNet/tools/infer.py b/benchmark/PaddleOCR_DBNet/tools/infer.py new file mode 100644 index 00000000..24e919c3 --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/tools/infer.py @@ -0,0 +1,298 @@ +# Copyright (c) 2021 PaddlePaddle Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import sys +import pathlib +__dir__ = pathlib.Path(os.path.abspath(__file__)) +sys.path.append(str(__dir__)) +sys.path.append(str(__dir__.parent.parent)) + +import cv2 +import paddle +from paddle import inference +import numpy as np +from PIL import Image + +from paddle.vision import transforms +from tools.predict import resize_image +from post_processing import get_post_processing +from utils.util import draw_bbox, save_result + + +class InferenceEngine(object): + """InferenceEngine + + Inference engina class which contains preprocess, run, postprocess + """ + + def __init__(self, args): + """ + Args: + args: Parameters generated using argparser. + Returns: None + """ + super().__init__() + self.args = args + + # init inference engine + self.predictor, self.config, self.input_tensor, self.output_tensor = self.load_predictor( + os.path.join(args.model_dir, "inference.pdmodel"), + os.path.join(args.model_dir, "inference.pdiparams")) + + # build transforms + self.transforms = transforms.Compose([ + transforms.ToTensor(), transforms.Normalize( + mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) + ]) + + # wamrup + if self.args.warmup > 0: + for idx in range(args.warmup): + print(idx) + x = np.random.rand(1, 3, self.args.crop_size, + self.args.crop_size).astype("float32") + self.input_tensor.copy_from_cpu(x) + self.predictor.run() + self.output_tensor.copy_to_cpu() + + self.post_process = get_post_processing({ + 'type': 'SegDetectorRepresenter', + 'args': { + 'thresh': 0.3, + 'box_thresh': 0.7, + 'max_candidates': 1000, + 'unclip_ratio': 1.5 + } + }) + + def load_predictor(self, model_file_path, params_file_path): + """load_predictor + initialize the inference engine + Args: + model_file_path: inference model path (*.pdmodel) + model_file_path: inference parmaeter path (*.pdiparams) + Return: + predictor: Predictor created using Paddle Inference. + config: Configuration of the predictor. + input_tensor: Input tensor of the predictor. + output_tensor: Output tensor of the predictor. + """ + args = self.args + config = inference.Config(model_file_path, params_file_path) + if args.use_gpu: + config.enable_use_gpu(1000, 0) + if args.use_tensorrt: + config.enable_tensorrt_engine( + workspace_size=1 << 30, + precision_mode=precision, + max_batch_size=args.max_batch_size, + min_subgraph_size=args. + min_subgraph_size, # skip the minmum trt subgraph + use_calib_mode=False) + + # collect shape + trt_shape_f = os.path.join(model_dir, "_trt_dynamic_shape.txt") + + if not os.path.exists(trt_shape_f): + config.collect_shape_range_info(trt_shape_f) + logger.info( + f"collect dynamic shape info into : {trt_shape_f}") + try: + config.enable_tuned_tensorrt_dynamic_shape(trt_shape_f, + True) + except Exception as E: + logger.info(E) + logger.info("Please keep your paddlepaddle-gpu >= 2.3.0!") + else: + config.disable_gpu() + # The thread num should not be greater than the number of cores in the CPU. + if args.enable_mkldnn: + # cache 10 different shapes for mkldnn to avoid memory leak + config.set_mkldnn_cache_capacity(10) + config.enable_mkldnn() + if args.precision == "fp16": + config.enable_mkldnn_bfloat16() + if hasattr(args, "cpu_threads"): + config.set_cpu_math_library_num_threads(args.cpu_threads) + else: + # default cpu threads as 10 + config.set_cpu_math_library_num_threads(10) + + # enable memory optim + config.enable_memory_optim() + config.disable_glog_info() + + config.switch_use_feed_fetch_ops(False) + config.switch_ir_optim(True) + + # create predictor + predictor = inference.create_predictor(config) + + # get input and output tensor property + input_names = predictor.get_input_names() + input_tensor = predictor.get_input_handle(input_names[0]) + + output_names = predictor.get_output_names() + output_tensor = predictor.get_output_handle(output_names[0]) + + return predictor, config, input_tensor, output_tensor + + def preprocess(self, img_path, short_size): + """preprocess + Preprocess to the input. + Args: + img_path: Image path. + Returns: Input data after preprocess. + """ + img = cv2.imread(img_path, 1) + img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + h, w = img.shape[:2] + img = resize_image(img, short_size) + img = self.transforms(img) + img = np.expand_dims(img, axis=0) + shape_info = {'shape': [(h, w)]} + return img, shape_info + + def postprocess(self, x, shape_info, is_output_polygon): + """postprocess + Postprocess to the inference engine output. + Args: + x: Inference engine output. + Returns: Output data after argmax. + """ + box_list, score_list = self.post_process( + shape_info, x, is_output_polygon=is_output_polygon) + box_list, score_list = box_list[0], score_list[0] + if len(box_list) > 0: + if is_output_polygon: + idx = [x.sum() > 0 for x in box_list] + box_list = [box_list[i] for i, v in enumerate(idx) if v] + score_list = [score_list[i] for i, v in enumerate(idx) if v] + else: + idx = box_list.reshape(box_list.shape[0], -1).sum( + axis=1) > 0 # 去掉全为0的框 + box_list, score_list = box_list[idx], score_list[idx] + else: + box_list, score_list = [], [] + return box_list, score_list + + def run(self, x): + """run + Inference process using inference engine. + Args: + x: Input data after preprocess. + Returns: Inference engine output + """ + self.input_tensor.copy_from_cpu(x) + self.predictor.run() + output = self.output_tensor.copy_to_cpu() + return output + + +def get_args(add_help=True): + """ + parse args + """ + import argparse + + def str2bool(v): + return v.lower() in ("true", "t", "1") + + parser = argparse.ArgumentParser( + description="PaddlePaddle Classification Training", add_help=add_help) + + parser.add_argument("--model_dir", default=None, help="inference model dir") + parser.add_argument("--batch_size", type=int, default=1) + parser.add_argument( + "--short_size", default=1024, type=int, help="short size") + parser.add_argument("--img_path", default="./images/demo.jpg") + + parser.add_argument( + "--benchmark", default=False, type=str2bool, help="benchmark") + parser.add_argument("--warmup", default=0, type=int, help="warmup iter") + parser.add_argument( + '--polygon', action='store_true', help='output polygon or box') + + parser.add_argument("--use_gpu", type=str2bool, default=True) + parser.add_argument("--use_tensorrt", type=str2bool, default=False) + parser.add_argument("--precision", type=str, default="fp32") + parser.add_argument("--gpu_mem", type=int, default=500) + parser.add_argument("--gpu_id", type=int, default=0) + parser.add_argument("--enable_mkldnn", type=str2bool, default=False) + parser.add_argument("--cpu_threads", type=int, default=10) + + args = parser.parse_args() + return args + + +def main(args): + """ + Main inference function. + Args: + args: Parameters generated using argparser. + Returns: + class_id: Class index of the input. + prob: : Probability of the input. + """ + inference_engine = InferenceEngine(args) + + # init benchmark + if args.benchmark: + import auto_log + autolog = auto_log.AutoLogger( + model_name="db", + batch_size=args.batch_size, + inference_config=inference_engine.config, + gpu_ids="auto" if args.use_gpu else None) + + # enable benchmark + if args.benchmark: + autolog.times.start() + + # preprocess + img, shape_info = inference_engine.preprocess(args.img_path, + args.short_size) + + if args.benchmark: + autolog.times.stamp() + + output = inference_engine.run(img) + + if args.benchmark: + autolog.times.stamp() + + # postprocess + box_list, score_list = inference_engine.postprocess(output, shape_info, + args.polygon) + + if args.benchmark: + autolog.times.stamp() + autolog.times.end(stamp=True) + autolog.report() + + img = draw_bbox(cv2.imread(args.img_path)[:, :, ::-1], box_list) + # 保存结果到路径 + os.makedirs('output', exist_ok=True) + img_path = pathlib.Path(args.img_path) + output_path = os.path.join('output', img_path.stem + '_infer_result.jpg') + cv2.imwrite(output_path, img[:, :, ::-1]) + save_result( + output_path.replace('_infer_result.jpg', '.txt'), box_list, score_list, + args.polygon) + + +if __name__ == "__main__": + args = get_args() + main(args) diff --git a/benchmark/PaddleOCR_DBNet/tools/predict.py b/benchmark/PaddleOCR_DBNet/tools/predict.py new file mode 100644 index 00000000..51beffd1 --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/tools/predict.py @@ -0,0 +1,178 @@ +# -*- coding: utf-8 -*- +# @Time : 2019/8/24 12:06 +# @Author : zhoujun + +import os +import sys +import pathlib +__dir__ = pathlib.Path(os.path.abspath(__file__)) +sys.path.append(str(__dir__)) +sys.path.append(str(__dir__.parent.parent)) + +import time +import cv2 +import paddle + +from data_loader import get_transforms +from models import build_model +from post_processing import get_post_processing + + +def resize_image(img, short_size): + height, width, _ = img.shape + if height < width: + new_height = short_size + new_width = new_height / height * width + else: + new_width = short_size + new_height = new_width / width * height + new_height = int(round(new_height / 32) * 32) + new_width = int(round(new_width / 32) * 32) + resized_img = cv2.resize(img, (new_width, new_height)) + return resized_img + + +class PaddleModel: + def __init__(self, model_path, post_p_thre=0.7, gpu_id=None): + ''' + 初始化模型 + :param model_path: 模型地址(可以是模型的参数或者参数和计算图一起保存的文件) + :param gpu_id: 在哪一块gpu上运行 + ''' + self.gpu_id = gpu_id + + if self.gpu_id is not None and isinstance( + self.gpu_id, int) and paddle.device.is_compiled_with_cuda(): + paddle.device.set_device("gpu:{}".format(self.gpu_id)) + else: + paddle.device.set_device("cpu") + checkpoint = paddle.load(model_path) + + config = checkpoint['config'] + config['arch']['backbone']['pretrained'] = False + self.model = build_model(config['arch']) + self.post_process = get_post_processing(config['post_processing']) + self.post_process.box_thresh = post_p_thre + self.img_mode = config['dataset']['train']['dataset']['args'][ + 'img_mode'] + self.model.set_state_dict(checkpoint['state_dict']) + self.model.eval() + + self.transform = [] + for t in config['dataset']['train']['dataset']['args']['transforms']: + if t['type'] in ['ToTensor', 'Normalize']: + self.transform.append(t) + self.transform = get_transforms(self.transform) + + def predict(self, + img_path: str, + is_output_polygon=False, + short_size: int=1024): + ''' + 对传入的图像进行预测,支持图像地址,opecv 读取图片,偏慢 + :param img_path: 图像地址 + :param is_numpy: + :return: + ''' + assert os.path.exists(img_path), 'file is not exists' + img = cv2.imread(img_path, 1 if self.img_mode != 'GRAY' else 0) + if self.img_mode == 'RGB': + img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + h, w = img.shape[:2] + img = resize_image(img, short_size) + # 将图片由(w,h)变为(1,img_channel,h,w) + tensor = self.transform(img) + tensor = tensor.unsqueeze_(0) + + batch = {'shape': [(h, w)]} + with paddle.no_grad(): + start = time.time() + preds = self.model(tensor) + box_list, score_list = self.post_process( + batch, preds, is_output_polygon=is_output_polygon) + box_list, score_list = box_list[0], score_list[0] + if len(box_list) > 0: + if is_output_polygon: + idx = [x.sum() > 0 for x in box_list] + box_list = [box_list[i] for i, v in enumerate(idx) if v] + score_list = [score_list[i] for i, v in enumerate(idx) if v] + else: + idx = box_list.reshape(box_list.shape[0], -1).sum( + axis=1) > 0 # 去掉全为0的框 + box_list, score_list = box_list[idx], score_list[idx] + else: + box_list, score_list = [], [] + t = time.time() - start + return preds[0, 0, :, :].detach().cpu().numpy(), box_list, score_list, t + + +def save_depoly(net, input, save_path): + input_spec = [ + paddle.static.InputSpec( + shape=[None, 3, None, None], dtype="float32") + ] + net = paddle.jit.to_static(net, input_spec=input_spec) + + # save static model for inference directly + paddle.jit.save(net, save_path) + + +def init_args(): + import argparse + parser = argparse.ArgumentParser(description='DBNet.paddle') + parser.add_argument('--model_path', default=r'model_best.pth', type=str) + parser.add_argument( + '--input_folder', + default='./test/input', + type=str, + help='img path for predict') + parser.add_argument( + '--output_folder', + default='./test/output', + type=str, + help='img path for output') + parser.add_argument('--gpu', default=0, type=int, help='gpu for inference') + parser.add_argument( + '--thre', default=0.3, type=float, help='the thresh of post_processing') + parser.add_argument( + '--polygon', action='store_true', help='output polygon or box') + parser.add_argument('--show', action='store_true', help='show result') + parser.add_argument( + '--save_result', + action='store_true', + help='save box and score to txt file') + args = parser.parse_args() + return args + + +if __name__ == '__main__': + import pathlib + from tqdm import tqdm + import matplotlib.pyplot as plt + from utils.util import show_img, draw_bbox, save_result, get_image_file_list + + args = init_args() + print(args) + # 初始化网络 + model = PaddleModel(args.model_path, post_p_thre=args.thre, gpu_id=args.gpu) + img_folder = pathlib.Path(args.input_folder) + for img_path in tqdm(get_image_file_list(args.input_folder)): + preds, boxes_list, score_list, t = model.predict( + img_path, is_output_polygon=args.polygon) + img = draw_bbox(cv2.imread(img_path)[:, :, ::-1], boxes_list) + if args.show: + show_img(preds) + show_img(img, title=os.path.basename(img_path)) + plt.show() + # 保存结果到路径 + os.makedirs(args.output_folder, exist_ok=True) + img_path = pathlib.Path(img_path) + output_path = os.path.join(args.output_folder, + img_path.stem + '_result.jpg') + pred_path = os.path.join(args.output_folder, + img_path.stem + '_pred.jpg') + cv2.imwrite(output_path, img[:, :, ::-1]) + cv2.imwrite(pred_path, preds * 255) + save_result( + output_path.replace('_result.jpg', '.txt'), boxes_list, score_list, + args.polygon) diff --git a/benchmark/PaddleOCR_DBNet/tools/train.py b/benchmark/PaddleOCR_DBNet/tools/train.py new file mode 100644 index 00000000..403d6185 --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/tools/train.py @@ -0,0 +1,61 @@ +import os +import sys +import pathlib +__dir__ = pathlib.Path(os.path.abspath(__file__)) +sys.path.append(str(__dir__)) +sys.path.append(str(__dir__.parent.parent)) + +import paddle +import paddle.distributed as dist +from utils import Config, ArgsParser + + +def init_args(): + parser = ArgsParser() + args = parser.parse_args() + return args + + +def main(config, profiler_options): + from models import build_model, build_loss + from data_loader import get_dataloader + from trainer import Trainer + from post_processing import get_post_processing + from utils import get_metric + if paddle.device.cuda.device_count() > 1: + dist.init_parallel_env() + config['distributed'] = True + else: + config['distributed'] = False + train_loader = get_dataloader(config['dataset']['train'], + config['distributed']) + assert train_loader is not None + if 'validate' in config['dataset']: + validate_loader = get_dataloader(config['dataset']['validate'], False) + else: + validate_loader = None + criterion = build_loss(config['loss']) + config['arch']['backbone']['in_channels'] = 3 if config['dataset']['train'][ + 'dataset']['args']['img_mode'] != 'GRAY' else 1 + model = build_model(config['arch']) + # set @to_static for benchmark, skip this by default. + post_p = get_post_processing(config['post_processing']) + metric = get_metric(config['metric']) + trainer = Trainer( + config=config, + model=model, + criterion=criterion, + train_loader=train_loader, + post_process=post_p, + metric_cls=metric, + validate_loader=validate_loader, + profiler_options=profiler_options) + trainer.train() + + +if __name__ == '__main__': + args = init_args() + assert os.path.exists(args.config_file) + config = Config(args.config_file) + config.merge_dict(args.opt) + main(config.cfg, args.profiler_options) diff --git a/benchmark/PaddleOCR_DBNet/trainer/__init__.py b/benchmark/PaddleOCR_DBNet/trainer/__init__.py new file mode 100644 index 00000000..76c7392d --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/trainer/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# @Time : 2019/8/23 21:58 +# @Author : zhoujun +from .trainer import Trainer \ No newline at end of file diff --git a/benchmark/PaddleOCR_DBNet/trainer/trainer.py b/benchmark/PaddleOCR_DBNet/trainer/trainer.py new file mode 100644 index 00000000..34b259f3 --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/trainer/trainer.py @@ -0,0 +1,230 @@ +# -*- coding: utf-8 -*- +# @Time : 2019/8/23 21:58 +# @Author : zhoujun +import time + +import paddle +from tqdm import tqdm + +from base import BaseTrainer +from utils import runningScore, cal_text_score, Polynomial, profiler + + +class Trainer(BaseTrainer): + def __init__(self, + config, + model, + criterion, + train_loader, + validate_loader, + metric_cls, + post_process=None, + profiler_options=None): + super(Trainer, self).__init__(config, model, criterion, train_loader, + validate_loader, metric_cls, post_process) + self.profiler_options = profiler_options + self.enable_eval = config['trainer'].get('enable_eval', True) + + def _train_epoch(self, epoch): + self.model.train() + total_samples = 0 + train_reader_cost = 0.0 + train_batch_cost = 0.0 + reader_start = time.time() + epoch_start = time.time() + train_loss = 0. + running_metric_text = runningScore(2) + + for i, batch in enumerate(self.train_loader): + profiler.add_profiler_step(self.profiler_options) + if i >= self.train_loader_len: + break + self.global_step += 1 + lr = self.optimizer.get_lr() + + cur_batch_size = batch['img'].shape[0] + + train_reader_cost += time.time() - reader_start + if self.amp: + with paddle.amp.auto_cast( + enable='gpu' in paddle.device.get_device(), + custom_white_list=self.amp.get('custom_white_list', []), + custom_black_list=self.amp.get('custom_black_list', []), + level=self.amp.get('level', 'O2')): + preds = self.model(batch['img']) + loss_dict = self.criterion(preds.astype(paddle.float32), batch) + scaled_loss = self.amp['scaler'].scale(loss_dict['loss']) + scaled_loss.backward() + self.amp['scaler'].minimize(self.optimizer, scaled_loss) + else: + preds = self.model(batch['img']) + loss_dict = self.criterion(preds, batch) + # backward + loss_dict['loss'].backward() + self.optimizer.step() + self.lr_scheduler.step() + self.optimizer.clear_grad() + + train_batch_time = time.time() - reader_start + train_batch_cost += train_batch_time + total_samples += cur_batch_size + + # acc iou + score_shrink_map = cal_text_score( + preds[:, 0, :, :], + batch['shrink_map'], + batch['shrink_mask'], + running_metric_text, + thred=self.config['post_processing']['args']['thresh']) + + # loss 和 acc 记录到日志 + loss_str = 'loss: {:.4f}, '.format(loss_dict['loss'].item()) + for idx, (key, value) in enumerate(loss_dict.items()): + loss_dict[key] = value.item() + if key == 'loss': + continue + loss_str += '{}: {:.4f}'.format(key, loss_dict[key]) + if idx < len(loss_dict) - 1: + loss_str += ', ' + + train_loss += loss_dict['loss'] + acc = score_shrink_map['Mean Acc'] + iou_shrink_map = score_shrink_map['Mean IoU'] + + if self.global_step % self.log_iter == 0: + self.logger_info( + '[{}/{}], [{}/{}], global_step: {}, ips: {:.1f} samples/sec, avg_reader_cost: {:.5f} s, avg_batch_cost: {:.5f} s, avg_samples: {}, acc: {:.4f}, iou_shrink_map: {:.4f}, {}lr:{:.6}, time:{:.2f}'. + format(epoch, self.epochs, i + 1, self.train_loader_len, + self.global_step, total_samples / train_batch_cost, + train_reader_cost / self.log_iter, train_batch_cost / + self.log_iter, total_samples / self.log_iter, acc, + iou_shrink_map, loss_str, lr, train_batch_cost)) + total_samples = 0 + train_reader_cost = 0.0 + train_batch_cost = 0.0 + + if self.visualdl_enable and paddle.distributed.get_rank() == 0: + # write tensorboard + for key, value in loss_dict.items(): + self.writer.add_scalar('TRAIN/LOSS/{}'.format(key), value, + self.global_step) + self.writer.add_scalar('TRAIN/ACC_IOU/acc', acc, + self.global_step) + self.writer.add_scalar('TRAIN/ACC_IOU/iou_shrink_map', + iou_shrink_map, self.global_step) + self.writer.add_scalar('TRAIN/lr', lr, self.global_step) + reader_start = time.time() + return { + 'train_loss': train_loss / self.train_loader_len, + 'lr': lr, + 'time': time.time() - epoch_start, + 'epoch': epoch + } + + def _eval(self, epoch): + self.model.eval() + raw_metrics = [] + total_frame = 0.0 + total_time = 0.0 + for i, batch in tqdm( + enumerate(self.validate_loader), + total=len(self.validate_loader), + desc='test model'): + with paddle.no_grad(): + start = time.time() + if self.amp: + with paddle.amp.auto_cast( + enable='gpu' in paddle.device.get_device(), + custom_white_list=self.amp.get('custom_white_list', + []), + custom_black_list=self.amp.get('custom_black_list', + []), + level=self.amp.get('level', 'O2')): + preds = self.model(batch['img']) + preds = preds.astype(paddle.float32) + else: + preds = self.model(batch['img']) + boxes, scores = self.post_process( + batch, + preds, + is_output_polygon=self.metric_cls.is_output_polygon) + total_frame += batch['img'].shape[0] + total_time += time.time() - start + raw_metric = self.metric_cls.validate_measure(batch, + (boxes, scores)) + raw_metrics.append(raw_metric) + metrics = self.metric_cls.gather_measure(raw_metrics) + self.logger_info('FPS:{}'.format(total_frame / total_time)) + return metrics['recall'].avg, metrics['precision'].avg, metrics[ + 'fmeasure'].avg + + def _on_epoch_finish(self): + self.logger_info('[{}/{}], train_loss: {:.4f}, time: {:.4f}, lr: {}'. + format(self.epoch_result['epoch'], self.epochs, self. + epoch_result['train_loss'], self.epoch_result[ + 'time'], self.epoch_result['lr'])) + net_save_path = '{}/model_latest.pth'.format(self.checkpoint_dir) + net_save_path_best = '{}/model_best.pth'.format(self.checkpoint_dir) + + if paddle.distributed.get_rank() == 0: + self._save_checkpoint(self.epoch_result['epoch'], net_save_path) + save_best = False + if self.validate_loader is not None and self.metric_cls is not None and self.enable_eval: # 使用f1作为最优模型指标 + recall, precision, hmean = self._eval(self.epoch_result[ + 'epoch']) + + if self.visualdl_enable: + self.writer.add_scalar('EVAL/recall', recall, + self.global_step) + self.writer.add_scalar('EVAL/precision', precision, + self.global_step) + self.writer.add_scalar('EVAL/hmean', hmean, + self.global_step) + self.logger_info( + 'test: recall: {:.6f}, precision: {:.6f}, hmean: {:.6f}'. + format(recall, precision, hmean)) + + if hmean >= self.metrics['hmean']: + save_best = True + self.metrics['train_loss'] = self.epoch_result['train_loss'] + self.metrics['hmean'] = hmean + self.metrics['precision'] = precision + self.metrics['recall'] = recall + self.metrics['best_model_epoch'] = self.epoch_result[ + 'epoch'] + else: + if self.epoch_result['train_loss'] <= self.metrics[ + 'train_loss']: + save_best = True + self.metrics['train_loss'] = self.epoch_result['train_loss'] + self.metrics['best_model_epoch'] = self.epoch_result[ + 'epoch'] + best_str = 'current best, ' + for k, v in self.metrics.items(): + best_str += '{}: {:.6f}, '.format(k, v) + self.logger_info(best_str) + if save_best: + import shutil + shutil.copy(net_save_path, net_save_path_best) + self.logger_info("Saving current best: {}".format( + net_save_path_best)) + else: + self.logger_info("Saving checkpoint: {}".format(net_save_path)) + + def _on_train_finish(self): + if self.enable_eval: + for k, v in self.metrics.items(): + self.logger_info('{}:{}'.format(k, v)) + self.logger_info('finish train') + + def _initialize_scheduler(self): + if self.config['lr_scheduler']['type'] == 'Polynomial': + self.config['lr_scheduler']['args']['epochs'] = self.config[ + 'trainer']['epochs'] + self.config['lr_scheduler']['args']['step_each_epoch'] = len( + self.train_loader) + self.lr_scheduler = Polynomial( + **self.config['lr_scheduler']['args'])() + else: + self.lr_scheduler = self._initialize('lr_scheduler', + paddle.optimizer.lr) diff --git a/benchmark/PaddleOCR_DBNet/utils/__init__.py b/benchmark/PaddleOCR_DBNet/utils/__init__.py new file mode 100644 index 00000000..194e0b82 --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/utils/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +# @Time : 2019/8/23 21:58 +# @Author : zhoujun +from .util import * +from .metrics import * +from .schedulers import * +from .cal_recall.script import cal_recall_precison_f1 +from .ocr_metric import get_metric diff --git a/benchmark/PaddleOCR_DBNet/utils/cal_recall/__init__.py b/benchmark/PaddleOCR_DBNet/utils/cal_recall/__init__.py new file mode 100644 index 00000000..0db38a8a --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/utils/cal_recall/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# @Time : 1/16/19 6:40 AM +# @Author : zhoujun +from .script import cal_recall_precison_f1 +__all__ = ['cal_recall_precison_f1'] diff --git a/benchmark/PaddleOCR_DBNet/utils/cal_recall/rrc_evaluation_funcs.py b/benchmark/PaddleOCR_DBNet/utils/cal_recall/rrc_evaluation_funcs.py new file mode 100644 index 00000000..4e12ee66 --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/utils/cal_recall/rrc_evaluation_funcs.py @@ -0,0 +1,479 @@ +#!/usr/bin/env python2 +#encoding: UTF-8 +import json +import sys +sys.path.append('./') +import zipfile +import re +import sys +import os +import codecs +import traceback +import numpy as np +from utils import order_points_clockwise + + +def print_help(): + sys.stdout.write( + 'Usage: python %s.py -g= -s= [-o= -p=]' + % sys.argv[0]) + sys.exit(2) + + +def load_zip_file_keys(file, fileNameRegExp=''): + """ + Returns an array with the entries of the ZIP file that match with the regular expression. + The key's are the names or the file or the capturing group definied in the fileNameRegExp + """ + try: + archive = zipfile.ZipFile(file, mode='r', allowZip64=True) + except: + raise Exception('Error loading the ZIP archive.') + + pairs = [] + + for name in archive.namelist(): + addFile = True + keyName = name + if fileNameRegExp != "": + m = re.match(fileNameRegExp, name) + if m == None: + addFile = False + else: + if len(m.groups()) > 0: + keyName = m.group(1) + + if addFile: + pairs.append(keyName) + + return pairs + + +def load_zip_file(file, fileNameRegExp='', allEntries=False): + """ + Returns an array with the contents (filtered by fileNameRegExp) of a ZIP file. + The key's are the names or the file or the capturing group definied in the fileNameRegExp + allEntries validates that all entries in the ZIP file pass the fileNameRegExp + """ + try: + archive = zipfile.ZipFile(file, mode='r', allowZip64=True) + except: + raise Exception('Error loading the ZIP archive') + + pairs = [] + for name in archive.namelist(): + addFile = True + keyName = name + if fileNameRegExp != "": + m = re.match(fileNameRegExp, name) + if m == None: + addFile = False + else: + if len(m.groups()) > 0: + keyName = m.group(1) + + if addFile: + pairs.append([keyName, archive.read(name)]) + else: + if allEntries: + raise Exception('ZIP entry not valid: %s' % name) + + return dict(pairs) + + +def load_folder_file(file, fileNameRegExp='', allEntries=False): + """ + Returns an array with the contents (filtered by fileNameRegExp) of a ZIP file. + The key's are the names or the file or the capturing group definied in the fileNameRegExp + allEntries validates that all entries in the ZIP file pass the fileNameRegExp + """ + pairs = [] + for name in os.listdir(file): + addFile = True + keyName = name + if fileNameRegExp != "": + m = re.match(fileNameRegExp, name) + if m == None: + addFile = False + else: + if len(m.groups()) > 0: + keyName = m.group(1) + + if addFile: + pairs.append([keyName, open(os.path.join(file, name)).read()]) + else: + if allEntries: + raise Exception('ZIP entry not valid: %s' % name) + + return dict(pairs) + + +def decode_utf8(raw): + """ + Returns a Unicode object on success, or None on failure + """ + try: + raw = codecs.decode(raw, 'utf-8', 'replace') + #extracts BOM if exists + raw = raw.encode('utf8') + if raw.startswith(codecs.BOM_UTF8): + raw = raw.replace(codecs.BOM_UTF8, '', 1) + return raw.decode('utf-8') + except: + return None + + +def validate_lines_in_file(fileName, + file_contents, + CRLF=True, + LTRB=True, + withTranscription=False, + withConfidence=False, + imWidth=0, + imHeight=0): + """ + This function validates that all lines of the file calling the Line validation function for each line + """ + utf8File = decode_utf8(file_contents) + if (utf8File is None): + raise Exception("The file %s is not UTF-8" % fileName) + + lines = utf8File.split("\r\n" if CRLF else "\n") + for line in lines: + line = line.replace("\r", "").replace("\n", "") + if (line != ""): + try: + validate_tl_line(line, LTRB, withTranscription, withConfidence, + imWidth, imHeight) + except Exception as e: + raise Exception( + ("Line in sample not valid. Sample: %s Line: %s Error: %s" % + (fileName, line, str(e))).encode('utf-8', 'replace')) + + +def validate_tl_line(line, + LTRB=True, + withTranscription=True, + withConfidence=True, + imWidth=0, + imHeight=0): + """ + Validate the format of the line. If the line is not valid an exception will be raised. + If maxWidth and maxHeight are specified, all points must be inside the imgage bounds. + Posible values are: + LTRB=True: xmin,ymin,xmax,ymax[,confidence][,transcription] + LTRB=False: x1,y1,x2,y2,x3,y3,x4,y4[,confidence][,transcription] + """ + get_tl_line_values(line, LTRB, withTranscription, withConfidence, imWidth, + imHeight) + + +def get_tl_line_values(line, + LTRB=True, + withTranscription=False, + withConfidence=False, + imWidth=0, + imHeight=0): + """ + Validate the format of the line. If the line is not valid an exception will be raised. + If maxWidth and maxHeight are specified, all points must be inside the imgage bounds. + Posible values are: + LTRB=True: xmin,ymin,xmax,ymax[,confidence][,transcription] + LTRB=False: x1,y1,x2,y2,x3,y3,x4,y4[,confidence][,transcription] + Returns values from a textline. Points , [Confidences], [Transcriptions] + """ + confidence = 0.0 + transcription = "" + points = [] + + numPoints = 4 + + if LTRB: + + numPoints = 4 + + if withTranscription and withConfidence: + m = re.match( + r'^\s*(-?[0-9]+)\s*,\s*(-?[0-9]+)\s*,\s*([0-9]+)\s*,\s*([0-9]+)\s*,\s*([0-1].?[0-9]*)\s*,(.*)$', + line) + if m == None: + m = re.match( + r'^\s*(-?[0-9]+)\s*,\s*(-?[0-9]+)\s*,\s*([0-9]+)\s*,\s*([0-9]+)\s*,\s*([0-1].?[0-9]*)\s*,(.*)$', + line) + raise Exception( + "Format incorrect. Should be: xmin,ymin,xmax,ymax,confidence,transcription" + ) + elif withConfidence: + m = re.match( + r'^\s*(-?[0-9]+)\s*,\s*(-?[0-9]+)\s*,\s*([0-9]+)\s*,\s*([0-9]+)\s*,\s*([0-1].?[0-9]*)\s*$', + line) + if m == None: + raise Exception( + "Format incorrect. Should be: xmin,ymin,xmax,ymax,confidence" + ) + elif withTranscription: + m = re.match( + r'^\s*(-?[0-9]+)\s*,\s*(-?[0-9]+)\s*,\s*([0-9]+)\s*,\s*([0-9]+)\s*,(.*)$', + line) + if m == None: + raise Exception( + "Format incorrect. Should be: xmin,ymin,xmax,ymax,transcription" + ) + else: + m = re.match( + r'^\s*(-?[0-9]+)\s*,\s*(-?[0-9]+)\s*,\s*([0-9]+)\s*,\s*([0-9]+)\s*,?\s*$', + line) + if m == None: + raise Exception( + "Format incorrect. Should be: xmin,ymin,xmax,ymax") + + xmin = int(m.group(1)) + ymin = int(m.group(2)) + xmax = int(m.group(3)) + ymax = int(m.group(4)) + if (xmax < xmin): + raise Exception("Xmax value (%s) not valid (Xmax < Xmin)." % (xmax)) + if (ymax < ymin): + raise Exception("Ymax value (%s) not valid (Ymax < Ymin)." % + (ymax)) + + points = [float(m.group(i)) for i in range(1, (numPoints + 1))] + + if (imWidth > 0 and imHeight > 0): + validate_point_inside_bounds(xmin, ymin, imWidth, imHeight) + validate_point_inside_bounds(xmax, ymax, imWidth, imHeight) + + else: + + numPoints = 8 + + if withTranscription and withConfidence: + m = re.match( + r'^\s*(-?[0-9]+)\s*,\s*(-?[0-9]+)\s*,\s*(-?[0-9]+)\s*,\s*(-?[0-9]+)\s*,\s*(-?[0-9]+)\s*,\s*(-?[0-9]+)\s*,\s*(-?[0-9]+)\s*,\s*(-?[0-9]+)\s*,\s*([0-1].?[0-9]*)\s*,(.*)$', + line) + if m == None: + raise Exception( + "Format incorrect. Should be: x1,y1,x2,y2,x3,y3,x4,y4,confidence,transcription" + ) + elif withConfidence: + m = re.match( + r'^\s*(-?[0-9]+)\s*,\s*(-?[0-9]+)\s*,\s*(-?[0-9]+)\s*,\s*(-?[0-9]+)\s*,\s*(-?[0-9]+)\s*,\s*(-?[0-9]+)\s*,\s*(-?[0-9]+)\s*,\s*(-?[0-9]+)\s*,\s*([0-1].?[0-9]*)\s*$', + line) + if m == None: + raise Exception( + "Format incorrect. Should be: x1,y1,x2,y2,x3,y3,x4,y4,confidence" + ) + elif withTranscription: + m = re.match( + r'^\s*(-?[0-9]+)\s*,\s*(-?[0-9]+)\s*,\s*(-?[0-9]+)\s*,\s*(-?[0-9]+)\s*,\s*(-?[0-9]+)\s*,\s*(-?[0-9]+)\s*,\s*(-?[0-9]+)\s*,\s*(-?[0-9]+)\s*,(.*)$', + line) + if m == None: + raise Exception( + "Format incorrect. Should be: x1,y1,x2,y2,x3,y3,x4,y4,transcription" + ) + else: + m = re.match( + r'^\s*(-?[0-9]+)\s*,\s*(-?[0-9]+)\s*,\s*(-?[0-9]+)\s*,\s*(-?[0-9]+)\s*,\s*(-?[0-9]+)\s*,\s*(-?[0-9]+)\s*,\s*(-?[0-9]+)\s*,\s*(-?[0-9]+)\s*$', + line) + if m == None: + raise Exception( + "Format incorrect. Should be: x1,y1,x2,y2,x3,y3,x4,y4") + + points = [float(m.group(i)) for i in range(1, (numPoints + 1))] + + points = order_points_clockwise(np.array(points).reshape(-1, + 2)).reshape(-1) + validate_clockwise_points(points) + + if (imWidth > 0 and imHeight > 0): + validate_point_inside_bounds(points[0], points[1], imWidth, + imHeight) + validate_point_inside_bounds(points[2], points[3], imWidth, + imHeight) + validate_point_inside_bounds(points[4], points[5], imWidth, + imHeight) + validate_point_inside_bounds(points[6], points[7], imWidth, + imHeight) + + if withConfidence: + try: + confidence = float(m.group(numPoints + 1)) + except ValueError: + raise Exception("Confidence value must be a float") + + if withTranscription: + posTranscription = numPoints + (2 if withConfidence else 1) + transcription = m.group(posTranscription) + m2 = re.match(r'^\s*\"(.*)\"\s*$', transcription) + if m2 != None: #Transcription with double quotes, we extract the value and replace escaped characters + transcription = m2.group(1).replace("\\\\", "\\").replace("\\\"", + "\"") + + return points, confidence, transcription + + +def validate_point_inside_bounds(x, y, imWidth, imHeight): + if (x < 0 or x > imWidth): + raise Exception("X value (%s) not valid. Image dimensions: (%s,%s)" % + (xmin, imWidth, imHeight)) + if (y < 0 or y > imHeight): + raise Exception( + "Y value (%s) not valid. Image dimensions: (%s,%s) Sample: %s Line:%s" + % (ymin, imWidth, imHeight)) + + +def validate_clockwise_points(points): + """ + Validates that the points that the 4 points that dlimite a polygon are in clockwise order. + """ + + if len(points) != 8: + raise Exception("Points list not valid." + str(len(points))) + + point = [[int(points[0]), int(points[1])], + [int(points[2]), int(points[3])], + [int(points[4]), int(points[5])], + [int(points[6]), int(points[7])]] + edge = [(point[1][0] - point[0][0]) * (point[1][1] + point[0][1]), + (point[2][0] - point[1][0]) * (point[2][1] + point[1][1]), + (point[3][0] - point[2][0]) * (point[3][1] + point[2][1]), + (point[0][0] - point[3][0]) * (point[0][1] + point[3][1])] + + summatory = edge[0] + edge[1] + edge[2] + edge[3] + if summatory > 0: + raise Exception( + "Points are not clockwise. The coordinates of bounding quadrilaterals have to be given in clockwise order. Regarding the correct interpretation of 'clockwise' remember that the image coordinate system used is the standard one, with the image origin at the upper left, the X axis extending to the right and Y axis extending downwards." + ) + + +def get_tl_line_values_from_file_contents(content, + CRLF=True, + LTRB=True, + withTranscription=False, + withConfidence=False, + imWidth=0, + imHeight=0, + sort_by_confidences=True): + """ + Returns all points, confindences and transcriptions of a file in lists. Valid line formats: + xmin,ymin,xmax,ymax,[confidence],[transcription] + x1,y1,x2,y2,x3,y3,x4,y4,[confidence],[transcription] + """ + pointsList = [] + transcriptionsList = [] + confidencesList = [] + + lines = content.split("\r\n" if CRLF else "\n") + for line in lines: + line = line.replace("\r", "").replace("\n", "") + if (line != ""): + points, confidence, transcription = get_tl_line_values( + line, LTRB, withTranscription, withConfidence, imWidth, + imHeight) + pointsList.append(points) + transcriptionsList.append(transcription) + confidencesList.append(confidence) + + if withConfidence and len(confidencesList) > 0 and sort_by_confidences: + import numpy as np + sorted_ind = np.argsort(-np.array(confidencesList)) + confidencesList = [confidencesList[i] for i in sorted_ind] + pointsList = [pointsList[i] for i in sorted_ind] + transcriptionsList = [transcriptionsList[i] for i in sorted_ind] + + return pointsList, confidencesList, transcriptionsList + + +def main_evaluation(p, + default_evaluation_params_fn, + validate_data_fn, + evaluate_method_fn, + show_result=True, + per_sample=True): + """ + This process validates a method, evaluates it and if it succed generates a ZIP file with a JSON entry for each sample. + Params: + p: Dictionary of parmeters with the GT/submission locations. If None is passed, the parameters send by the system are used. + default_evaluation_params_fn: points to a function that returns a dictionary with the default parameters used for the evaluation + validate_data_fn: points to a method that validates the corrct format of the submission + evaluate_method_fn: points to a function that evaluated the submission and return a Dictionary with the results + """ + evalParams = default_evaluation_params_fn() + if 'p' in p.keys(): + evalParams.update(p['p'] if isinstance(p['p'], dict) else json.loads(p[ + 'p'][1:-1])) + + resDict = { + 'calculated': True, + 'Message': '', + 'method': '{}', + 'per_sample': '{}' + } + try: + # validate_data_fn(p['g'], p['s'], evalParams) + evalData = evaluate_method_fn(p['g'], p['s'], evalParams) + resDict.update(evalData) + + except Exception as e: + traceback.print_exc() + resDict['Message'] = str(e) + resDict['calculated'] = False + + if 'o' in p: + if not os.path.exists(p['o']): + os.makedirs(p['o']) + + resultsOutputname = p['o'] + '/results.zip' + outZip = zipfile.ZipFile(resultsOutputname, mode='w', allowZip64=True) + + del resDict['per_sample'] + if 'output_items' in resDict.keys(): + del resDict['output_items'] + + outZip.writestr('method.json', json.dumps(resDict)) + + if not resDict['calculated']: + if show_result: + sys.stderr.write('Error!\n' + resDict['Message'] + '\n\n') + if 'o' in p: + outZip.close() + return resDict + + if 'o' in p: + if per_sample == True: + for k, v in evalData['per_sample'].iteritems(): + outZip.writestr(k + '.json', json.dumps(v)) + + if 'output_items' in evalData.keys(): + for k, v in evalData['output_items'].iteritems(): + outZip.writestr(k, v) + + outZip.close() + + if show_result: + sys.stdout.write("Calculated!") + sys.stdout.write(json.dumps(resDict['method'])) + + return resDict + + +def main_validation(default_evaluation_params_fn, validate_data_fn): + """ + This process validates a method + Params: + default_evaluation_params_fn: points to a function that returns a dictionary with the default parameters used for the evaluation + validate_data_fn: points to a method that validates the corrct format of the submission + """ + try: + p = dict([s[1:].split('=') for s in sys.argv[1:]]) + evalParams = default_evaluation_params_fn() + if 'p' in p.keys(): + evalParams.update(p['p'] if isinstance(p['p'], dict) else + json.loads(p['p'][1:-1])) + + validate_data_fn(p['g'], p['s'], evalParams) + print('SUCCESS') + sys.exit(0) + except Exception as e: + print(str(e)) + sys.exit(101) diff --git a/benchmark/PaddleOCR_DBNet/utils/cal_recall/script.py b/benchmark/PaddleOCR_DBNet/utils/cal_recall/script.py new file mode 100644 index 00000000..3b2f3916 --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/utils/cal_recall/script.py @@ -0,0 +1,350 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from collections import namedtuple +from . import rrc_evaluation_funcs +import Polygon as plg +import numpy as np + + +def default_evaluation_params(): + """ + default_evaluation_params: Default parameters to use for the validation and evaluation. + """ + return { + 'IOU_CONSTRAINT': 0.5, + 'AREA_PRECISION_CONSTRAINT': 0.5, + 'GT_SAMPLE_NAME_2_ID': 'gt_img_([0-9]+).txt', + 'DET_SAMPLE_NAME_2_ID': 'res_img_([0-9]+).txt', + 'LTRB': + False, # LTRB:2points(left,top,right,bottom) or 4 points(x1,y1,x2,y2,x3,y3,x4,y4) + 'CRLF': False, # Lines are delimited by Windows CRLF format + 'CONFIDENCES': + False, # Detections must include confidence value. AP will be calculated + 'PER_SAMPLE_RESULTS': + True # Generate per sample results and produce data for visualization + } + + +def validate_data(gtFilePath, submFilePath, evaluationParams): + """ + Method validate_data: validates that all files in the results folder are correct (have the correct name contents). + Validates also that there are no missing files in the folder. + If some error detected, the method raises the error + """ + gt = rrc_evaluation_funcs.load_folder_file( + gtFilePath, evaluationParams['GT_SAMPLE_NAME_2_ID']) + + subm = rrc_evaluation_funcs.load_folder_file( + submFilePath, evaluationParams['DET_SAMPLE_NAME_2_ID'], True) + + # Validate format of GroundTruth + for k in gt: + rrc_evaluation_funcs.validate_lines_in_file( + k, gt[k], evaluationParams['CRLF'], evaluationParams['LTRB'], True) + + # Validate format of results + for k in subm: + if (k in gt) == False: + raise Exception("The sample %s not present in GT" % k) + + rrc_evaluation_funcs.validate_lines_in_file( + k, subm[k], evaluationParams['CRLF'], evaluationParams['LTRB'], + False, evaluationParams['CONFIDENCES']) + + +def evaluate_method(gtFilePath, submFilePath, evaluationParams): + """ + Method evaluate_method: evaluate method and returns the results + Results. Dictionary with the following values: + - method (required) Global method metrics. Ex: { 'Precision':0.8,'Recall':0.9 } + - samples (optional) Per sample metrics. Ex: {'sample1' : { 'Precision':0.8,'Recall':0.9 } , 'sample2' : { 'Precision':0.8,'Recall':0.9 } + """ + + def polygon_from_points(points): + """ + Returns a Polygon object to use with the Polygon2 class from a list of 8 points: x1,y1,x2,y2,x3,y3,x4,y4 + """ + resBoxes = np.empty([1, 8], dtype='int32') + resBoxes[0, 0] = int(points[0]) + resBoxes[0, 4] = int(points[1]) + resBoxes[0, 1] = int(points[2]) + resBoxes[0, 5] = int(points[3]) + resBoxes[0, 2] = int(points[4]) + resBoxes[0, 6] = int(points[5]) + resBoxes[0, 3] = int(points[6]) + resBoxes[0, 7] = int(points[7]) + pointMat = resBoxes[0].reshape([2, 4]).T + return plg.Polygon(pointMat) + + def rectangle_to_polygon(rect): + resBoxes = np.empty([1, 8], dtype='int32') + resBoxes[0, 0] = int(rect.xmin) + resBoxes[0, 4] = int(rect.ymax) + resBoxes[0, 1] = int(rect.xmin) + resBoxes[0, 5] = int(rect.ymin) + resBoxes[0, 2] = int(rect.xmax) + resBoxes[0, 6] = int(rect.ymin) + resBoxes[0, 3] = int(rect.xmax) + resBoxes[0, 7] = int(rect.ymax) + + pointMat = resBoxes[0].reshape([2, 4]).T + + return plg.Polygon(pointMat) + + def rectangle_to_points(rect): + points = [ + int(rect.xmin), int(rect.ymax), int(rect.xmax), int(rect.ymax), + int(rect.xmax), int(rect.ymin), int(rect.xmin), int(rect.ymin) + ] + return points + + def get_union(pD, pG): + areaA = pD.area() + areaB = pG.area() + return areaA + areaB - get_intersection(pD, pG) + + def get_intersection_over_union(pD, pG): + try: + return get_intersection(pD, pG) / get_union(pD, pG) + except: + return 0 + + def get_intersection(pD, pG): + pInt = pD & pG + if len(pInt) == 0: + return 0 + return pInt.area() + + def compute_ap(confList, matchList, numGtCare): + correct = 0 + AP = 0 + if len(confList) > 0: + confList = np.array(confList) + matchList = np.array(matchList) + sorted_ind = np.argsort(-confList) + confList = confList[sorted_ind] + matchList = matchList[sorted_ind] + for n in range(len(confList)): + match = matchList[n] + if match: + correct += 1 + AP += float(correct) / (n + 1) + + if numGtCare > 0: + AP /= numGtCare + + return AP + + perSampleMetrics = {} + + matchedSum = 0 + + Rectangle = namedtuple('Rectangle', 'xmin ymin xmax ymax') + + gt = rrc_evaluation_funcs.load_folder_file( + gtFilePath, evaluationParams['GT_SAMPLE_NAME_2_ID']) + subm = rrc_evaluation_funcs.load_folder_file( + submFilePath, evaluationParams['DET_SAMPLE_NAME_2_ID'], True) + + numGlobalCareGt = 0 + numGlobalCareDet = 0 + + arrGlobalConfidences = [] + arrGlobalMatches = [] + + for resFile in gt: + + gtFile = gt[resFile] # rrc_evaluation_funcs.decode_utf8(gt[resFile]) + recall = 0 + precision = 0 + hmean = 0 + + detMatched = 0 + + iouMat = np.empty([1, 1]) + + gtPols = [] + detPols = [] + + gtPolPoints = [] + detPolPoints = [] + + # Array of Ground Truth Polygons' keys marked as don't Care + gtDontCarePolsNum = [] + # Array of Detected Polygons' matched with a don't Care GT + detDontCarePolsNum = [] + + pairs = [] + detMatchedNums = [] + + arrSampleConfidences = [] + arrSampleMatch = [] + sampleAP = 0 + + evaluationLog = "" + + pointsList, _, transcriptionsList = rrc_evaluation_funcs.get_tl_line_values_from_file_contents( + gtFile, evaluationParams['CRLF'], evaluationParams['LTRB'], True, + False) + for n in range(len(pointsList)): + points = pointsList[n] + transcription = transcriptionsList[n] + dontCare = transcription == "###" + if evaluationParams['LTRB']: + gtRect = Rectangle(*points) + gtPol = rectangle_to_polygon(gtRect) + else: + gtPol = polygon_from_points(points) + gtPols.append(gtPol) + gtPolPoints.append(points) + if dontCare: + gtDontCarePolsNum.append(len(gtPols) - 1) + + evaluationLog += "GT polygons: " + str(len(gtPols)) + ( + " (" + str(len(gtDontCarePolsNum)) + " don't care)\n" + if len(gtDontCarePolsNum) > 0 else "\n") + + if resFile in subm: + + detFile = subm[ + resFile] # rrc_evaluation_funcs.decode_utf8(subm[resFile]) + + pointsList, confidencesList, _ = rrc_evaluation_funcs.get_tl_line_values_from_file_contents( + detFile, evaluationParams['CRLF'], evaluationParams['LTRB'], + False, evaluationParams['CONFIDENCES']) + for n in range(len(pointsList)): + points = pointsList[n] + + if evaluationParams['LTRB']: + detRect = Rectangle(*points) + detPol = rectangle_to_polygon(detRect) + else: + detPol = polygon_from_points(points) + detPols.append(detPol) + detPolPoints.append(points) + if len(gtDontCarePolsNum) > 0: + for dontCarePol in gtDontCarePolsNum: + dontCarePol = gtPols[dontCarePol] + intersected_area = get_intersection(dontCarePol, detPol) + pdDimensions = detPol.area() + precision = 0 if pdDimensions == 0 else intersected_area / pdDimensions + if (precision > + evaluationParams['AREA_PRECISION_CONSTRAINT']): + detDontCarePolsNum.append(len(detPols) - 1) + break + + evaluationLog += "DET polygons: " + str(len(detPols)) + ( + " (" + str(len(detDontCarePolsNum)) + " don't care)\n" + if len(detDontCarePolsNum) > 0 else "\n") + + if len(gtPols) > 0 and len(detPols) > 0: + # Calculate IoU and precision matrixs + outputShape = [len(gtPols), len(detPols)] + iouMat = np.empty(outputShape) + gtRectMat = np.zeros(len(gtPols), np.int8) + detRectMat = np.zeros(len(detPols), np.int8) + for gtNum in range(len(gtPols)): + for detNum in range(len(detPols)): + pG = gtPols[gtNum] + pD = detPols[detNum] + iouMat[gtNum, detNum] = get_intersection_over_union(pD, + pG) + + for gtNum in range(len(gtPols)): + for detNum in range(len(detPols)): + if gtRectMat[gtNum] == 0 and detRectMat[ + detNum] == 0 and gtNum not in gtDontCarePolsNum and detNum not in detDontCarePolsNum: + if iouMat[gtNum, detNum] > evaluationParams[ + 'IOU_CONSTRAINT']: + gtRectMat[gtNum] = 1 + detRectMat[detNum] = 1 + detMatched += 1 + pairs.append({'gt': gtNum, 'det': detNum}) + detMatchedNums.append(detNum) + evaluationLog += "Match GT #" + str( + gtNum) + " with Det #" + str(detNum) + "\n" + + if evaluationParams['CONFIDENCES']: + for detNum in range(len(detPols)): + if detNum not in detDontCarePolsNum: + # we exclude the don't care detections + match = detNum in detMatchedNums + + arrSampleConfidences.append(confidencesList[detNum]) + arrSampleMatch.append(match) + + arrGlobalConfidences.append(confidencesList[detNum]) + arrGlobalMatches.append(match) + + numGtCare = (len(gtPols) - len(gtDontCarePolsNum)) + numDetCare = (len(detPols) - len(detDontCarePolsNum)) + if numGtCare == 0: + recall = float(1) + precision = float(0) if numDetCare > 0 else float(1) + sampleAP = precision + else: + recall = float(detMatched) / numGtCare + precision = 0 if numDetCare == 0 else float(detMatched) / numDetCare + if evaluationParams['CONFIDENCES'] and evaluationParams[ + 'PER_SAMPLE_RESULTS']: + sampleAP = compute_ap(arrSampleConfidences, arrSampleMatch, + numGtCare) + + hmean = 0 if (precision + recall) == 0 else 2.0 * precision * recall / ( + precision + recall) + + matchedSum += detMatched + numGlobalCareGt += numGtCare + numGlobalCareDet += numDetCare + + if evaluationParams['PER_SAMPLE_RESULTS']: + perSampleMetrics[resFile] = { + 'precision': precision, + 'recall': recall, + 'hmean': hmean, + 'pairs': pairs, + 'AP': sampleAP, + 'iouMat': [] if len(detPols) > 100 else iouMat.tolist(), + 'gtPolPoints': gtPolPoints, + 'detPolPoints': detPolPoints, + 'gtDontCare': gtDontCarePolsNum, + 'detDontCare': detDontCarePolsNum, + 'evaluationParams': evaluationParams, + 'evaluationLog': evaluationLog + } + + # Compute MAP and MAR + AP = 0 + if evaluationParams['CONFIDENCES']: + AP = compute_ap(arrGlobalConfidences, arrGlobalMatches, numGlobalCareGt) + + methodRecall = 0 if numGlobalCareGt == 0 else float( + matchedSum) / numGlobalCareGt + methodPrecision = 0 if numGlobalCareDet == 0 else float( + matchedSum) / numGlobalCareDet + methodHmean = 0 if methodRecall + methodPrecision == 0 else 2 * methodRecall * methodPrecision / ( + methodRecall + methodPrecision) + + methodMetrics = { + 'precision': methodPrecision, + 'recall': methodRecall, + 'hmean': methodHmean, + 'AP': AP + } + + resDict = { + 'calculated': True, + 'Message': '', + 'method': methodMetrics, + 'per_sample': perSampleMetrics + } + + return resDict + + +def cal_recall_precison_f1(gt_path, result_path, show_result=False): + p = {'g': gt_path, 's': result_path} + result = rrc_evaluation_funcs.main_evaluation(p, default_evaluation_params, + validate_data, + evaluate_method, show_result) + return result['method'] diff --git a/benchmark/PaddleOCR_DBNet/utils/compute_mean_std.py b/benchmark/PaddleOCR_DBNet/utils/compute_mean_std.py new file mode 100644 index 00000000..5d0ab5cd --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/utils/compute_mean_std.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# @Time : 2019/12/7 14:46 +# @Author : zhoujun + +import numpy as np +import cv2 +import os +import random +from tqdm import tqdm +# calculate means and std +train_txt_path = './train_val_list.txt' + +CNum = 10000 # 挑选多少图片进行计算 + +img_h, img_w = 640, 640 +imgs = np.zeros([img_w, img_h, 3, 1]) +means, stdevs = [], [] + +with open(train_txt_path, 'r') as f: + lines = f.readlines() + random.shuffle(lines) # shuffle , 随机挑选图片 + + for i in tqdm(range(CNum)): + img_path = lines[i].split('\t')[0] + + img = cv2.imread(img_path) + img = cv2.resize(img, (img_h, img_w)) + img = img[:, :, :, np.newaxis] + + imgs = np.concatenate((imgs, img), axis=3) +# print(i) + +imgs = imgs.astype(np.float32) / 255. + +for i in tqdm(range(3)): + pixels = imgs[:, :, i, :].ravel() # 拉成一行 + means.append(np.mean(pixels)) + stdevs.append(np.std(pixels)) + +# cv2 读取的图像格式为BGR,PIL/Skimage读取到的都是RGB不用转 +means.reverse() # BGR --> RGB +stdevs.reverse() + +print("normMean = {}".format(means)) +print("normStd = {}".format(stdevs)) +print('transforms.Normalize(normMean = {}, normStd = {})'.format(means, stdevs)) \ No newline at end of file diff --git a/benchmark/PaddleOCR_DBNet/utils/make_trainfile.py b/benchmark/PaddleOCR_DBNet/utils/make_trainfile.py new file mode 100644 index 00000000..9b7ae70f --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/utils/make_trainfile.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# @Time : 2019/8/24 12:06 +# @Author : zhoujun +import os +import glob +import pathlib + +data_path = r'test' +# data_path/img 存放图片 +# data_path/gt 存放标签文件 + +f_w = open(os.path.join(data_path, 'test.txt'), 'w', encoding='utf8') +for img_path in glob.glob(data_path + '/img/*.jpg', recursive=True): + d = pathlib.Path(img_path) + label_path = os.path.join(data_path, 'gt', ('gt_' + str(d.stem) + '.txt')) + if os.path.exists(img_path) and os.path.exists(label_path): + print(img_path, label_path) + else: + print('不存在', img_path, label_path) + f_w.write('{}\t{}\n'.format(img_path, label_path)) +f_w.close() \ No newline at end of file diff --git a/benchmark/PaddleOCR_DBNet/utils/metrics.py b/benchmark/PaddleOCR_DBNet/utils/metrics.py new file mode 100644 index 00000000..e9c54b8d --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/utils/metrics.py @@ -0,0 +1,58 @@ +# Adapted from score written by wkentaro +# https://github.com/wkentaro/pytorch-fcn/blob/master/torchfcn/utils.py + +import numpy as np + + +class runningScore(object): + def __init__(self, n_classes): + self.n_classes = n_classes + self.confusion_matrix = np.zeros((n_classes, n_classes)) + + def _fast_hist(self, label_true, label_pred, n_class): + mask = (label_true >= 0) & (label_true < n_class) + + if np.sum((label_pred[mask] < 0)) > 0: + print(label_pred[label_pred < 0]) + hist = np.bincount( + n_class * label_true[mask].astype(int) + label_pred[mask], + minlength=n_class**2).reshape(n_class, n_class) + return hist + + def update(self, label_trues, label_preds): + # print label_trues.dtype, label_preds.dtype + for lt, lp in zip(label_trues, label_preds): + try: + self.confusion_matrix += self._fast_hist(lt.flatten(), + lp.flatten(), + self.n_classes) + except: + pass + + def get_scores(self): + """Returns accuracy score evaluation result. + - overall accuracy + - mean accuracy + - mean IU + - fwavacc + """ + hist = self.confusion_matrix + acc = np.diag(hist).sum() / (hist.sum() + 0.0001) + acc_cls = np.diag(hist) / (hist.sum(axis=1) + 0.0001) + acc_cls = np.nanmean(acc_cls) + iu = np.diag(hist) / ( + hist.sum(axis=1) + hist.sum(axis=0) - np.diag(hist) + 0.0001) + mean_iu = np.nanmean(iu) + freq = hist.sum(axis=1) / (hist.sum() + 0.0001) + fwavacc = (freq[freq > 0] * iu[freq > 0]).sum() + cls_iu = dict(zip(range(self.n_classes), iu)) + + return { + 'Overall Acc': acc, + 'Mean Acc': acc_cls, + 'FreqW Acc': fwavacc, + 'Mean IoU': mean_iu, + }, cls_iu + + def reset(self): + self.confusion_matrix = np.zeros((self.n_classes, self.n_classes)) diff --git a/benchmark/PaddleOCR_DBNet/utils/ocr_metric/__init__.py b/benchmark/PaddleOCR_DBNet/utils/ocr_metric/__init__.py new file mode 100644 index 00000000..3e7c51cf --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/utils/ocr_metric/__init__.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# @Time : 2019/12/5 15:36 +# @Author : zhoujun +from .icdar2015 import QuadMetric + + +def get_metric(config): + try: + if 'args' not in config: + args = {} + else: + args = config['args'] + if isinstance(args, dict): + cls = eval(config['type'])(**args) + else: + cls = eval(config['type'])(args) + return cls + except: + return None \ No newline at end of file diff --git a/benchmark/PaddleOCR_DBNet/utils/ocr_metric/icdar2015/__init__.py b/benchmark/PaddleOCR_DBNet/utils/ocr_metric/icdar2015/__init__.py new file mode 100644 index 00000000..375ae557 --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/utils/ocr_metric/icdar2015/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# @Time : 2019/12/5 15:36 +# @Author : zhoujun + +from .quad_metric import QuadMetric \ No newline at end of file diff --git a/benchmark/PaddleOCR_DBNet/utils/ocr_metric/icdar2015/detection/__init__.py b/benchmark/PaddleOCR_DBNet/utils/ocr_metric/icdar2015/detection/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/benchmark/PaddleOCR_DBNet/utils/ocr_metric/icdar2015/detection/deteval.py b/benchmark/PaddleOCR_DBNet/utils/ocr_metric/icdar2015/detection/deteval.py new file mode 100644 index 00000000..c5dcfc4b --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/utils/ocr_metric/icdar2015/detection/deteval.py @@ -0,0 +1,389 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +import math +from collections import namedtuple +import numpy as np +from shapely.geometry import Polygon + + +class DetectionDetEvalEvaluator(object): + def __init__(self, + area_recall_constraint=0.8, + area_precision_constraint=0.4, + ev_param_ind_center_diff_thr=1, + mtype_oo_o=1.0, + mtype_om_o=0.8, + mtype_om_m=1.0): + + self.area_recall_constraint = area_recall_constraint + self.area_precision_constraint = area_precision_constraint + self.ev_param_ind_center_diff_thr = ev_param_ind_center_diff_thr + self.mtype_oo_o = mtype_oo_o + self.mtype_om_o = mtype_om_o + self.mtype_om_m = mtype_om_m + + def evaluate_image(self, gt, pred): + def get_union(pD, pG): + return Polygon(pD).union(Polygon(pG)).area + + def get_intersection_over_union(pD, pG): + return get_intersection(pD, pG) / get_union(pD, pG) + + def get_intersection(pD, pG): + return Polygon(pD).intersection(Polygon(pG)).area + + def one_to_one_match(row, col): + cont = 0 + for j in range(len(recallMat[0])): + if recallMat[row, + j] >= self.area_recall_constraint and precisionMat[ + row, j] >= self.area_precision_constraint: + cont = cont + 1 + if (cont != 1): + return False + cont = 0 + for i in range(len(recallMat)): + if recallMat[ + i, col] >= self.area_recall_constraint and precisionMat[ + i, col] >= self.area_precision_constraint: + cont = cont + 1 + if (cont != 1): + return False + + if recallMat[row, + col] >= self.area_recall_constraint and precisionMat[ + row, col] >= self.area_precision_constraint: + return True + return False + + def num_overlaps_gt(gtNum): + cont = 0 + for detNum in range(len(detRects)): + if detNum not in detDontCareRectsNum: + if recallMat[gtNum, detNum] > 0: + cont = cont + 1 + return cont + + def num_overlaps_det(detNum): + cont = 0 + for gtNum in range(len(recallMat)): + if gtNum not in gtDontCareRectsNum: + if recallMat[gtNum, detNum] > 0: + cont = cont + 1 + return cont + + def is_single_overlap(row, col): + if num_overlaps_gt(row) == 1 and num_overlaps_det(col) == 1: + return True + else: + return False + + def one_to_many_match(gtNum): + many_sum = 0 + detRects = [] + for detNum in range(len(recallMat[0])): + if gtRectMat[gtNum] == 0 and detRectMat[ + detNum] == 0 and detNum not in detDontCareRectsNum: + if precisionMat[gtNum, + detNum] >= self.area_precision_constraint: + many_sum += recallMat[gtNum, detNum] + detRects.append(detNum) + if round(many_sum, 4) >= self.area_recall_constraint: + return True, detRects + else: + return False, [] + + def many_to_one_match(detNum): + many_sum = 0 + gtRects = [] + for gtNum in range(len(recallMat)): + if gtRectMat[gtNum] == 0 and detRectMat[ + detNum] == 0 and gtNum not in gtDontCareRectsNum: + if recallMat[gtNum, detNum] >= self.area_recall_constraint: + many_sum += precisionMat[gtNum, detNum] + gtRects.append(gtNum) + if round(many_sum, 4) >= self.area_precision_constraint: + return True, gtRects + else: + return False, [] + + def center_distance(r1, r2): + return ((np.mean(r1, axis=0) - np.mean(r2, axis=0))**2).sum()**0.5 + + def diag(r): + r = np.array(r) + return ((r[:, 0].max() - r[:, 0].min())**2 + + (r[:, 1].max() - r[:, 1].min())**2)**0.5 + + perSampleMetrics = {} + + recall = 0 + precision = 0 + hmean = 0 + recallAccum = 0. + precisionAccum = 0. + gtRects = [] + detRects = [] + gtPolPoints = [] + detPolPoints = [] + gtDontCareRectsNum = [ + ] #Array of Ground Truth Rectangles' keys marked as don't Care + detDontCareRectsNum = [ + ] #Array of Detected Rectangles' matched with a don't Care GT + pairs = [] + evaluationLog = "" + + recallMat = np.empty([1, 1]) + precisionMat = np.empty([1, 1]) + + for n in range(len(gt)): + points = gt[n]['points'] + # transcription = gt[n]['text'] + dontCare = gt[n]['ignore'] + + if not Polygon(points).is_valid or not Polygon(points).is_simple: + continue + + gtRects.append(points) + gtPolPoints.append(points) + if dontCare: + gtDontCareRectsNum.append(len(gtRects) - 1) + + evaluationLog += "GT rectangles: " + str(len(gtRects)) + ( + " (" + str(len(gtDontCareRectsNum)) + " don't care)\n" + if len(gtDontCareRectsNum) > 0 else "\n") + + for n in range(len(pred)): + points = pred[n]['points'] + + if not Polygon(points).is_valid or not Polygon(points).is_simple: + continue + + detRect = points + detRects.append(detRect) + detPolPoints.append(points) + if len(gtDontCareRectsNum) > 0: + for dontCareRectNum in gtDontCareRectsNum: + dontCareRect = gtRects[dontCareRectNum] + intersected_area = get_intersection(dontCareRect, detRect) + rdDimensions = Polygon(detRect).area + if (rdDimensions == 0): + precision = 0 + else: + precision = intersected_area / rdDimensions + if (precision > self.area_precision_constraint): + detDontCareRectsNum.append(len(detRects) - 1) + break + + evaluationLog += "DET rectangles: " + str(len(detRects)) + ( + " (" + str(len(detDontCareRectsNum)) + " don't care)\n" + if len(detDontCareRectsNum) > 0 else "\n") + + if len(gtRects) == 0: + recall = 1 + precision = 0 if len(detRects) > 0 else 1 + + if len(detRects) > 0: + #Calculate recall and precision matrixs + outputShape = [len(gtRects), len(detRects)] + recallMat = np.empty(outputShape) + precisionMat = np.empty(outputShape) + gtRectMat = np.zeros(len(gtRects), np.int8) + detRectMat = np.zeros(len(detRects), np.int8) + for gtNum in range(len(gtRects)): + for detNum in range(len(detRects)): + rG = gtRects[gtNum] + rD = detRects[detNum] + intersected_area = get_intersection(rG, rD) + rgDimensions = Polygon(rG).area + rdDimensions = Polygon(rD).area + recallMat[ + gtNum, + detNum] = 0 if rgDimensions == 0 else intersected_area / rgDimensions + precisionMat[ + gtNum, + detNum] = 0 if rdDimensions == 0 else intersected_area / rdDimensions + + # Find one-to-one matches + evaluationLog += "Find one-to-one matches\n" + for gtNum in range(len(gtRects)): + for detNum in range(len(detRects)): + if gtRectMat[gtNum] == 0 and detRectMat[ + detNum] == 0 and gtNum not in gtDontCareRectsNum and detNum not in detDontCareRectsNum: + match = one_to_one_match(gtNum, detNum) + if match is True: + #in deteval we have to make other validation before mark as one-to-one + if is_single_overlap(gtNum, detNum) is True: + rG = gtRects[gtNum] + rD = detRects[detNum] + normDist = center_distance(rG, rD) + normDist /= diag(rG) + diag(rD) + normDist *= 2.0 + if normDist < self.ev_param_ind_center_diff_thr: + gtRectMat[gtNum] = 1 + detRectMat[detNum] = 1 + recallAccum += self.mtype_oo_o + precisionAccum += self.mtype_oo_o + pairs.append({ + 'gt': gtNum, + 'det': detNum, + 'type': 'OO' + }) + evaluationLog += "Match GT #" + str( + gtNum) + " with Det #" + str( + detNum) + "\n" + else: + evaluationLog += "Match Discarded GT #" + str( + gtNum) + " with Det #" + str( + detNum) + " normDist: " + str( + normDist) + " \n" + else: + evaluationLog += "Match Discarded GT #" + str( + gtNum) + " with Det #" + str( + detNum) + " not single overlap\n" + # Find one-to-many matches + evaluationLog += "Find one-to-many matches\n" + for gtNum in range(len(gtRects)): + if gtNum not in gtDontCareRectsNum: + match, matchesDet = one_to_many_match(gtNum) + if match is True: + evaluationLog += "num_overlaps_gt=" + str( + num_overlaps_gt(gtNum)) + #in deteval we have to make other validation before mark as one-to-one + if num_overlaps_gt(gtNum) >= 2: + gtRectMat[gtNum] = 1 + recallAccum += (self.mtype_oo_o + if len(matchesDet) == 1 else + self.mtype_om_o) + precisionAccum += (self.mtype_oo_o + if len(matchesDet) == 1 else + self.mtype_om_o * + len(matchesDet)) + pairs.append({ + 'gt': gtNum, + 'det': matchesDet, + 'type': 'OO' if len(matchesDet) == 1 else 'OM' + }) + for detNum in matchesDet: + detRectMat[detNum] = 1 + evaluationLog += "Match GT #" + str( + gtNum) + " with Det #" + str(matchesDet) + "\n" + else: + evaluationLog += "Match Discarded GT #" + str( + gtNum) + " with Det #" + str( + matchesDet) + " not single overlap\n" + + # Find many-to-one matches + evaluationLog += "Find many-to-one matches\n" + for detNum in range(len(detRects)): + if detNum not in detDontCareRectsNum: + match, matchesGt = many_to_one_match(detNum) + if match is True: + #in deteval we have to make other validation before mark as one-to-one + if num_overlaps_det(detNum) >= 2: + detRectMat[detNum] = 1 + recallAccum += (self.mtype_oo_o + if len(matchesGt) == 1 else + self.mtype_om_m * len(matchesGt)) + precisionAccum += (self.mtype_oo_o + if len(matchesGt) == 1 else + self.mtype_om_m) + pairs.append({ + 'gt': matchesGt, + 'det': detNum, + 'type': 'OO' if len(matchesGt) == 1 else 'MO' + }) + for gtNum in matchesGt: + gtRectMat[gtNum] = 1 + evaluationLog += "Match GT #" + str( + matchesGt) + " with Det #" + str(detNum) + "\n" + else: + evaluationLog += "Match Discarded GT #" + str( + matchesGt) + " with Det #" + str( + detNum) + " not single overlap\n" + + numGtCare = (len(gtRects) - len(gtDontCareRectsNum)) + if numGtCare == 0: + recall = float(1) + precision = float(0) if len(detRects) > 0 else float(1) + else: + recall = float(recallAccum) / numGtCare + precision = float(0) if ( + len(detRects) - len(detDontCareRectsNum) + ) == 0 else float(precisionAccum) / ( + len(detRects) - len(detDontCareRectsNum)) + hmean = 0 if (precision + recall + ) == 0 else 2.0 * precision * recall / ( + precision + recall) + + numGtCare = len(gtRects) - len(gtDontCareRectsNum) + numDetCare = len(detRects) - len(detDontCareRectsNum) + + perSampleMetrics = { + 'precision': precision, + 'recall': recall, + 'hmean': hmean, + 'pairs': pairs, + 'recallMat': [] if len(detRects) > 100 else recallMat.tolist(), + 'precisionMat': [] + if len(detRects) > 100 else precisionMat.tolist(), + 'gtPolPoints': gtPolPoints, + 'detPolPoints': detPolPoints, + 'gtCare': numGtCare, + 'detCare': numDetCare, + 'gtDontCare': gtDontCareRectsNum, + 'detDontCare': detDontCareRectsNum, + 'recallAccum': recallAccum, + 'precisionAccum': precisionAccum, + 'evaluationLog': evaluationLog + } + + return perSampleMetrics + + def combine_results(self, results): + numGt = 0 + numDet = 0 + methodRecallSum = 0 + methodPrecisionSum = 0 + + for result in results: + numGt += result['gtCare'] + numDet += result['detCare'] + methodRecallSum += result['recallAccum'] + methodPrecisionSum += result['precisionAccum'] + + methodRecall = 0 if numGt == 0 else methodRecallSum / numGt + methodPrecision = 0 if numDet == 0 else methodPrecisionSum / numDet + methodHmean = 0 if methodRecall + methodPrecision == 0 else 2 * methodRecall * methodPrecision / ( + methodRecall + methodPrecision) + + methodMetrics = { + 'precision': methodPrecision, + 'recall': methodRecall, + 'hmean': methodHmean + } + + return methodMetrics + + +if __name__ == '__main__': + evaluator = DetectionDetEvalEvaluator() + gts = [[{ + 'points': [(0, 0), (1, 0), (1, 1), (0, 1)], + 'text': 1234, + 'ignore': False, + }, { + 'points': [(2, 2), (3, 2), (3, 3), (2, 3)], + 'text': 5678, + 'ignore': True, + }]] + preds = [[{ + 'points': [(0.1, 0.1), (1, 0), (1, 1), (0, 1)], + 'text': 123, + 'ignore': False, + }]] + results = [] + for gt, pred in zip(gts, preds): + results.append(evaluator.evaluate_image(gt, pred)) + metrics = evaluator.combine_results(results) + print(metrics) diff --git a/benchmark/PaddleOCR_DBNet/utils/ocr_metric/icdar2015/detection/icdar2013.py b/benchmark/PaddleOCR_DBNet/utils/ocr_metric/icdar2015/detection/icdar2013.py new file mode 100644 index 00000000..7e8c86aa --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/utils/ocr_metric/icdar2015/detection/icdar2013.py @@ -0,0 +1,346 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +import math +from collections import namedtuple +import numpy as np +from shapely.geometry import Polygon + + +class DetectionICDAR2013Evaluator(object): + def __init__(self, + area_recall_constraint=0.8, + area_precision_constraint=0.4, + ev_param_ind_center_diff_thr=1, + mtype_oo_o=1.0, + mtype_om_o=0.8, + mtype_om_m=1.0): + + self.area_recall_constraint = area_recall_constraint + self.area_precision_constraint = area_precision_constraint + self.ev_param_ind_center_diff_thr = ev_param_ind_center_diff_thr + self.mtype_oo_o = mtype_oo_o + self.mtype_om_o = mtype_om_o + self.mtype_om_m = mtype_om_m + + def evaluate_image(self, gt, pred): + def get_union(pD, pG): + return Polygon(pD).union(Polygon(pG)).area + + def get_intersection_over_union(pD, pG): + return get_intersection(pD, pG) / get_union(pD, pG) + + def get_intersection(pD, pG): + return Polygon(pD).intersection(Polygon(pG)).area + + def one_to_one_match(row, col): + cont = 0 + for j in range(len(recallMat[0])): + if recallMat[row, + j] >= self.area_recall_constraint and precisionMat[ + row, j] >= self.area_precision_constraint: + cont = cont + 1 + if (cont != 1): + return False + cont = 0 + for i in range(len(recallMat)): + if recallMat[ + i, col] >= self.area_recall_constraint and precisionMat[ + i, col] >= self.area_precision_constraint: + cont = cont + 1 + if (cont != 1): + return False + + if recallMat[row, + col] >= self.area_recall_constraint and precisionMat[ + row, col] >= self.area_precision_constraint: + return True + return False + + def one_to_many_match(gtNum): + many_sum = 0 + detRects = [] + for detNum in range(len(recallMat[0])): + if gtRectMat[gtNum] == 0 and detRectMat[ + detNum] == 0 and detNum not in detDontCareRectsNum: + if precisionMat[gtNum, + detNum] >= self.area_precision_constraint: + many_sum += recallMat[gtNum, detNum] + detRects.append(detNum) + if round(many_sum, 4) >= self.area_recall_constraint: + return True, detRects + else: + return False, [] + + def many_to_one_match(detNum): + many_sum = 0 + gtRects = [] + for gtNum in range(len(recallMat)): + if gtRectMat[gtNum] == 0 and detRectMat[ + detNum] == 0 and gtNum not in gtDontCareRectsNum: + if recallMat[gtNum, detNum] >= self.area_recall_constraint: + many_sum += precisionMat[gtNum, detNum] + gtRects.append(gtNum) + if round(many_sum, 4) >= self.area_precision_constraint: + return True, gtRects + else: + return False, [] + + def center_distance(r1, r2): + return ((np.mean(r1, axis=0) - np.mean(r2, axis=0))**2).sum()**0.5 + + def diag(r): + r = np.array(r) + return ((r[:, 0].max() - r[:, 0].min())**2 + + (r[:, 1].max() - r[:, 1].min())**2)**0.5 + + perSampleMetrics = {} + + recall = 0 + precision = 0 + hmean = 0 + recallAccum = 0. + precisionAccum = 0. + gtRects = [] + detRects = [] + gtPolPoints = [] + detPolPoints = [] + gtDontCareRectsNum = [ + ] #Array of Ground Truth Rectangles' keys marked as don't Care + detDontCareRectsNum = [ + ] #Array of Detected Rectangles' matched with a don't Care GT + pairs = [] + evaluationLog = "" + + recallMat = np.empty([1, 1]) + precisionMat = np.empty([1, 1]) + + for n in range(len(gt)): + points = gt[n]['points'] + # transcription = gt[n]['text'] + dontCare = gt[n]['ignore'] + + if not Polygon(points).is_valid or not Polygon(points).is_simple: + continue + + gtRects.append(points) + gtPolPoints.append(points) + if dontCare: + gtDontCareRectsNum.append(len(gtRects) - 1) + + evaluationLog += "GT rectangles: " + str(len(gtRects)) + ( + " (" + str(len(gtDontCareRectsNum)) + " don't care)\n" + if len(gtDontCareRectsNum) > 0 else "\n") + + for n in range(len(pred)): + points = pred[n]['points'] + + if not Polygon(points).is_valid or not Polygon(points).is_simple: + continue + + detRect = points + detRects.append(detRect) + detPolPoints.append(points) + if len(gtDontCareRectsNum) > 0: + for dontCareRectNum in gtDontCareRectsNum: + dontCareRect = gtRects[dontCareRectNum] + intersected_area = get_intersection(dontCareRect, detRect) + rdDimensions = Polygon(detRect).area + if (rdDimensions == 0): + precision = 0 + else: + precision = intersected_area / rdDimensions + if (precision > self.area_precision_constraint): + detDontCareRectsNum.append(len(detRects) - 1) + break + + evaluationLog += "DET rectangles: " + str(len(detRects)) + ( + " (" + str(len(detDontCareRectsNum)) + " don't care)\n" + if len(detDontCareRectsNum) > 0 else "\n") + + if len(gtRects) == 0: + recall = 1 + precision = 0 if len(detRects) > 0 else 1 + + if len(detRects) > 0: + #Calculate recall and precision matrixs + outputShape = [len(gtRects), len(detRects)] + recallMat = np.empty(outputShape) + precisionMat = np.empty(outputShape) + gtRectMat = np.zeros(len(gtRects), np.int8) + detRectMat = np.zeros(len(detRects), np.int8) + for gtNum in range(len(gtRects)): + for detNum in range(len(detRects)): + rG = gtRects[gtNum] + rD = detRects[detNum] + intersected_area = get_intersection(rG, rD) + rgDimensions = Polygon(rG).area + rdDimensions = Polygon(rD).area + recallMat[ + gtNum, + detNum] = 0 if rgDimensions == 0 else intersected_area / rgDimensions + precisionMat[ + gtNum, + detNum] = 0 if rdDimensions == 0 else intersected_area / rdDimensions + + # Find one-to-one matches + evaluationLog += "Find one-to-one matches\n" + for gtNum in range(len(gtRects)): + for detNum in range(len(detRects)): + if gtRectMat[gtNum] == 0 and detRectMat[ + detNum] == 0 and gtNum not in gtDontCareRectsNum and detNum not in detDontCareRectsNum: + match = one_to_one_match(gtNum, detNum) + if match is True: + #in deteval we have to make other validation before mark as one-to-one + rG = gtRects[gtNum] + rD = detRects[detNum] + normDist = center_distance(rG, rD) + normDist /= diag(rG) + diag(rD) + normDist *= 2.0 + if normDist < self.ev_param_ind_center_diff_thr: + gtRectMat[gtNum] = 1 + detRectMat[detNum] = 1 + recallAccum += self.mtype_oo_o + precisionAccum += self.mtype_oo_o + pairs.append({ + 'gt': gtNum, + 'det': detNum, + 'type': 'OO' + }) + evaluationLog += "Match GT #" + str( + gtNum) + " with Det #" + str(detNum) + "\n" + else: + evaluationLog += "Match Discarded GT #" + str( + gtNum) + " with Det #" + str( + detNum) + " normDist: " + str( + normDist) + " \n" + # Find one-to-many matches + evaluationLog += "Find one-to-many matches\n" + for gtNum in range(len(gtRects)): + if gtNum not in gtDontCareRectsNum: + match, matchesDet = one_to_many_match(gtNum) + if match is True: + evaluationLog += "num_overlaps_gt=" + str( + num_overlaps_gt(gtNum)) + gtRectMat[gtNum] = 1 + recallAccum += (self.mtype_oo_o if len(matchesDet) == 1 + else self.mtype_om_o) + precisionAccum += (self.mtype_oo_o + if len(matchesDet) == 1 else + self.mtype_om_o * len(matchesDet)) + pairs.append({ + 'gt': gtNum, + 'det': matchesDet, + 'type': 'OO' if len(matchesDet) == 1 else 'OM' + }) + for detNum in matchesDet: + detRectMat[detNum] = 1 + evaluationLog += "Match GT #" + str( + gtNum) + " with Det #" + str(matchesDet) + "\n" + + # Find many-to-one matches + evaluationLog += "Find many-to-one matches\n" + for detNum in range(len(detRects)): + if detNum not in detDontCareRectsNum: + match, matchesGt = many_to_one_match(detNum) + if match is True: + detRectMat[detNum] = 1 + recallAccum += (self.mtype_oo_o if len(matchesGt) == 1 + else self.mtype_om_m * len(matchesGt)) + precisionAccum += (self.mtype_oo_o + if len(matchesGt) == 1 else + self.mtype_om_m) + pairs.append({ + 'gt': matchesGt, + 'det': detNum, + 'type': 'OO' if len(matchesGt) == 1 else 'MO' + }) + for gtNum in matchesGt: + gtRectMat[gtNum] = 1 + evaluationLog += "Match GT #" + str( + matchesGt) + " with Det #" + str(detNum) + "\n" + + numGtCare = (len(gtRects) - len(gtDontCareRectsNum)) + if numGtCare == 0: + recall = float(1) + precision = float(0) if len(detRects) > 0 else float(1) + else: + recall = float(recallAccum) / numGtCare + precision = float(0) if ( + len(detRects) - len(detDontCareRectsNum) + ) == 0 else float(precisionAccum) / ( + len(detRects) - len(detDontCareRectsNum)) + hmean = 0 if (precision + recall + ) == 0 else 2.0 * precision * recall / ( + precision + recall) + + numGtCare = len(gtRects) - len(gtDontCareRectsNum) + numDetCare = len(detRects) - len(detDontCareRectsNum) + + perSampleMetrics = { + 'precision': precision, + 'recall': recall, + 'hmean': hmean, + 'pairs': pairs, + 'recallMat': [] if len(detRects) > 100 else recallMat.tolist(), + 'precisionMat': [] + if len(detRects) > 100 else precisionMat.tolist(), + 'gtPolPoints': gtPolPoints, + 'detPolPoints': detPolPoints, + 'gtCare': numGtCare, + 'detCare': numDetCare, + 'gtDontCare': gtDontCareRectsNum, + 'detDontCare': detDontCareRectsNum, + 'recallAccum': recallAccum, + 'precisionAccum': precisionAccum, + 'evaluationLog': evaluationLog + } + + return perSampleMetrics + + def combine_results(self, results): + numGt = 0 + numDet = 0 + methodRecallSum = 0 + methodPrecisionSum = 0 + + for result in results: + numGt += result['gtCare'] + numDet += result['detCare'] + methodRecallSum += result['recallAccum'] + methodPrecisionSum += result['precisionAccum'] + + methodRecall = 0 if numGt == 0 else methodRecallSum / numGt + methodPrecision = 0 if numDet == 0 else methodPrecisionSum / numDet + methodHmean = 0 if methodRecall + methodPrecision == 0 else 2 * methodRecall * methodPrecision / ( + methodRecall + methodPrecision) + + methodMetrics = { + 'precision': methodPrecision, + 'recall': methodRecall, + 'hmean': methodHmean + } + + return methodMetrics + + +if __name__ == '__main__': + evaluator = DetectionICDAR2013Evaluator() + gts = [[{ + 'points': [(0, 0), (1, 0), (1, 1), (0, 1)], + 'text': 1234, + 'ignore': False, + }, { + 'points': [(2, 2), (3, 2), (3, 3), (2, 3)], + 'text': 5678, + 'ignore': True, + }]] + preds = [[{ + 'points': [(0.1, 0.1), (1, 0), (1, 1), (0, 1)], + 'text': 123, + 'ignore': False, + }]] + results = [] + for gt, pred in zip(gts, preds): + results.append(evaluator.evaluate_image(gt, pred)) + metrics = evaluator.combine_results(results) + print(metrics) diff --git a/benchmark/PaddleOCR_DBNet/utils/ocr_metric/icdar2015/detection/iou.py b/benchmark/PaddleOCR_DBNet/utils/ocr_metric/icdar2015/detection/iou.py new file mode 100644 index 00000000..5f9533b3 --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/utils/ocr_metric/icdar2015/detection/iou.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from collections import namedtuple +import numpy as np +from shapely.geometry import Polygon +import cv2 + + +def iou_rotate(box_a, box_b, method='union'): + rect_a = cv2.minAreaRect(box_a) + rect_b = cv2.minAreaRect(box_b) + r1 = cv2.rotatedRectangleIntersection(rect_a, rect_b) + if r1[0] == 0: + return 0 + else: + inter_area = cv2.contourArea(r1[1]) + area_a = cv2.contourArea(box_a) + area_b = cv2.contourArea(box_b) + union_area = area_a + area_b - inter_area + if union_area == 0 or inter_area == 0: + return 0 + if method == 'union': + iou = inter_area / union_area + elif method == 'intersection': + iou = inter_area / min(area_a, area_b) + else: + raise NotImplementedError + return iou + + +class DetectionIoUEvaluator(object): + def __init__(self, + is_output_polygon=False, + iou_constraint=0.5, + area_precision_constraint=0.5): + self.is_output_polygon = is_output_polygon + self.iou_constraint = iou_constraint + self.area_precision_constraint = area_precision_constraint + + def evaluate_image(self, gt, pred): + def get_union(pD, pG): + return Polygon(pD).union(Polygon(pG)).area + + def get_intersection_over_union(pD, pG): + return get_intersection(pD, pG) / get_union(pD, pG) + + def get_intersection(pD, pG): + return Polygon(pD).intersection(Polygon(pG)).area + + def compute_ap(confList, matchList, numGtCare): + correct = 0 + AP = 0 + if len(confList) > 0: + confList = np.array(confList) + matchList = np.array(matchList) + sorted_ind = np.argsort(-confList) + confList = confList[sorted_ind] + matchList = matchList[sorted_ind] + for n in range(len(confList)): + match = matchList[n] + if match: + correct += 1 + AP += float(correct) / (n + 1) + + if numGtCare > 0: + AP /= numGtCare + + return AP + + perSampleMetrics = {} + + matchedSum = 0 + + Rectangle = namedtuple('Rectangle', 'xmin ymin xmax ymax') + + numGlobalCareGt = 0 + numGlobalCareDet = 0 + + arrGlobalConfidences = [] + arrGlobalMatches = [] + + recall = 0 + precision = 0 + hmean = 0 + + detMatched = 0 + + iouMat = np.empty([1, 1]) + + gtPols = [] + detPols = [] + + gtPolPoints = [] + detPolPoints = [] + + # Array of Ground Truth Polygons' keys marked as don't Care + gtDontCarePolsNum = [] + # Array of Detected Polygons' matched with a don't Care GT + detDontCarePolsNum = [] + + pairs = [] + detMatchedNums = [] + + arrSampleConfidences = [] + arrSampleMatch = [] + + evaluationLog = "" + + for n in range(len(gt)): + points = gt[n]['points'] + # transcription = gt[n]['text'] + dontCare = gt[n]['ignore'] + + if not Polygon(points).is_valid or not Polygon(points).is_simple: + continue + + gtPol = points + gtPols.append(gtPol) + gtPolPoints.append(points) + if dontCare: + gtDontCarePolsNum.append(len(gtPols) - 1) + + evaluationLog += "GT polygons: " + str(len(gtPols)) + ( + " (" + str(len(gtDontCarePolsNum)) + " don't care)\n" + if len(gtDontCarePolsNum) > 0 else "\n") + + for n in range(len(pred)): + points = pred[n]['points'] + if not Polygon(points).is_valid or not Polygon(points).is_simple: + continue + + detPol = points + detPols.append(detPol) + detPolPoints.append(points) + if len(gtDontCarePolsNum) > 0: + for dontCarePol in gtDontCarePolsNum: + dontCarePol = gtPols[dontCarePol] + intersected_area = get_intersection(dontCarePol, detPol) + pdDimensions = Polygon(detPol).area + precision = 0 if pdDimensions == 0 else intersected_area / pdDimensions + if (precision > self.area_precision_constraint): + detDontCarePolsNum.append(len(detPols) - 1) + break + + evaluationLog += "DET polygons: " + str(len(detPols)) + ( + " (" + str(len(detDontCarePolsNum)) + " don't care)\n" + if len(detDontCarePolsNum) > 0 else "\n") + + if len(gtPols) > 0 and len(detPols) > 0: + # Calculate IoU and precision matrixs + outputShape = [len(gtPols), len(detPols)] + iouMat = np.empty(outputShape) + gtRectMat = np.zeros(len(gtPols), np.int8) + detRectMat = np.zeros(len(detPols), np.int8) + if self.is_output_polygon: + for gtNum in range(len(gtPols)): + for detNum in range(len(detPols)): + pG = gtPols[gtNum] + pD = detPols[detNum] + iouMat[gtNum, detNum] = get_intersection_over_union(pD, + pG) + else: + # gtPols = np.float32(gtPols) + # detPols = np.float32(detPols) + for gtNum in range(len(gtPols)): + for detNum in range(len(detPols)): + pG = np.float32(gtPols[gtNum]) + pD = np.float32(detPols[detNum]) + iouMat[gtNum, detNum] = iou_rotate(pD, pG) + for gtNum in range(len(gtPols)): + for detNum in range(len(detPols)): + if gtRectMat[gtNum] == 0 and detRectMat[ + detNum] == 0 and gtNum not in gtDontCarePolsNum and detNum not in detDontCarePolsNum: + if iouMat[gtNum, detNum] > self.iou_constraint: + gtRectMat[gtNum] = 1 + detRectMat[detNum] = 1 + detMatched += 1 + pairs.append({'gt': gtNum, 'det': detNum}) + detMatchedNums.append(detNum) + evaluationLog += "Match GT #" + \ + str(gtNum) + " with Det #" + str(detNum) + "\n" + + numGtCare = (len(gtPols) - len(gtDontCarePolsNum)) + numDetCare = (len(detPols) - len(detDontCarePolsNum)) + if numGtCare == 0: + recall = float(1) + precision = float(0) if numDetCare > 0 else float(1) + else: + recall = float(detMatched) / numGtCare + precision = 0 if numDetCare == 0 else float(detMatched) / numDetCare + + hmean = 0 if (precision + recall) == 0 else 2.0 * \ + precision * recall / (precision + recall) + + matchedSum += detMatched + numGlobalCareGt += numGtCare + numGlobalCareDet += numDetCare + + perSampleMetrics = { + 'precision': precision, + 'recall': recall, + 'hmean': hmean, + 'pairs': pairs, + 'iouMat': [] if len(detPols) > 100 else iouMat.tolist(), + 'gtPolPoints': gtPolPoints, + 'detPolPoints': detPolPoints, + 'gtCare': numGtCare, + 'detCare': numDetCare, + 'gtDontCare': gtDontCarePolsNum, + 'detDontCare': detDontCarePolsNum, + 'detMatched': detMatched, + 'evaluationLog': evaluationLog + } + + return perSampleMetrics + + def combine_results(self, results): + numGlobalCareGt = 0 + numGlobalCareDet = 0 + matchedSum = 0 + for result in results: + numGlobalCareGt += result['gtCare'] + numGlobalCareDet += result['detCare'] + matchedSum += result['detMatched'] + + methodRecall = 0 if numGlobalCareGt == 0 else float( + matchedSum) / numGlobalCareGt + methodPrecision = 0 if numGlobalCareDet == 0 else float( + matchedSum) / numGlobalCareDet + methodHmean = 0 if methodRecall + methodPrecision == 0 else 2 * \ + methodRecall * methodPrecision / ( + methodRecall + methodPrecision) + + methodMetrics = { + 'precision': methodPrecision, + 'recall': methodRecall, + 'hmean': methodHmean + } + + return methodMetrics + + +if __name__ == '__main__': + evaluator = DetectionIoUEvaluator() + preds = [[{ + 'points': [(0.1, 0.1), (0.5, 0), (0.5, 1), (0, 1)], + 'text': 1234, + 'ignore': False, + }, { + 'points': [(0.5, 0.1), (1, 0), (1, 1), (0.5, 1)], + 'text': 5678, + 'ignore': False, + }]] + gts = [[{ + 'points': [(0.1, 0.1), (1, 0), (1, 1), (0, 1)], + 'text': 123, + 'ignore': False, + }]] + results = [] + for gt, pred in zip(gts, preds): + results.append(evaluator.evaluate_image(gt, pred)) + metrics = evaluator.combine_results(results) + print(metrics) diff --git a/benchmark/PaddleOCR_DBNet/utils/ocr_metric/icdar2015/detection/mtwi2018.py b/benchmark/PaddleOCR_DBNet/utils/ocr_metric/icdar2015/detection/mtwi2018.py new file mode 100644 index 00000000..8e319aac --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/utils/ocr_metric/icdar2015/detection/mtwi2018.py @@ -0,0 +1,335 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +import math +from collections import namedtuple +import numpy as np +from shapely.geometry import Polygon + + +class DetectionMTWI2018Evaluator(object): + def __init__( + self, + area_recall_constraint=0.7, + area_precision_constraint=0.7, + ev_param_ind_center_diff_thr=1, ): + + self.area_recall_constraint = area_recall_constraint + self.area_precision_constraint = area_precision_constraint + self.ev_param_ind_center_diff_thr = ev_param_ind_center_diff_thr + + def evaluate_image(self, gt, pred): + def get_union(pD, pG): + return Polygon(pD).union(Polygon(pG)).area + + def get_intersection_over_union(pD, pG): + return get_intersection(pD, pG) / get_union(pD, pG) + + def get_intersection(pD, pG): + return Polygon(pD).intersection(Polygon(pG)).area + + def one_to_one_match(row, col): + cont = 0 + for j in range(len(recallMat[0])): + if recallMat[row, + j] >= self.area_recall_constraint and precisionMat[ + row, j] >= self.area_precision_constraint: + cont = cont + 1 + if (cont != 1): + return False + cont = 0 + for i in range(len(recallMat)): + if recallMat[ + i, col] >= self.area_recall_constraint and precisionMat[ + i, col] >= self.area_precision_constraint: + cont = cont + 1 + if (cont != 1): + return False + + if recallMat[row, + col] >= self.area_recall_constraint and precisionMat[ + row, col] >= self.area_precision_constraint: + return True + return False + + def one_to_many_match(gtNum): + many_sum = 0 + detRects = [] + for detNum in range(len(recallMat[0])): + if gtRectMat[gtNum] == 0 and detRectMat[ + detNum] == 0 and detNum not in detDontCareRectsNum: + if precisionMat[gtNum, + detNum] >= self.area_precision_constraint: + many_sum += recallMat[gtNum, detNum] + detRects.append(detNum) + if round(many_sum, 4) >= self.area_recall_constraint: + return True, detRects + else: + return False, [] + + def many_to_one_match(detNum): + many_sum = 0 + gtRects = [] + for gtNum in range(len(recallMat)): + if gtRectMat[gtNum] == 0 and detRectMat[ + detNum] == 0 and gtNum not in gtDontCareRectsNum: + if recallMat[gtNum, detNum] >= self.area_recall_constraint: + many_sum += precisionMat[gtNum, detNum] + gtRects.append(gtNum) + if round(many_sum, 4) >= self.area_precision_constraint: + return True, gtRects + else: + return False, [] + + def center_distance(r1, r2): + return ((np.mean(r1, axis=0) - np.mean(r2, axis=0))**2).sum()**0.5 + + def diag(r): + r = np.array(r) + return ((r[:, 0].max() - r[:, 0].min())**2 + + (r[:, 1].max() - r[:, 1].min())**2)**0.5 + + perSampleMetrics = {} + + recall = 0 + precision = 0 + hmean = 0 + recallAccum = 0. + precisionAccum = 0. + gtRects = [] + detRects = [] + gtPolPoints = [] + detPolPoints = [] + gtDontCareRectsNum = [ + ] #Array of Ground Truth Rectangles' keys marked as don't Care + detDontCareRectsNum = [ + ] #Array of Detected Rectangles' matched with a don't Care GT + pairs = [] + evaluationLog = "" + + recallMat = np.empty([1, 1]) + precisionMat = np.empty([1, 1]) + + for n in range(len(gt)): + points = gt[n]['points'] + # transcription = gt[n]['text'] + dontCare = gt[n]['ignore'] + + if not Polygon(points).is_valid or not Polygon(points).is_simple: + continue + + gtRects.append(points) + gtPolPoints.append(points) + if dontCare: + gtDontCareRectsNum.append(len(gtRects) - 1) + + evaluationLog += "GT rectangles: " + str(len(gtRects)) + ( + " (" + str(len(gtDontCareRectsNum)) + " don't care)\n" + if len(gtDontCareRectsNum) > 0 else "\n") + + for n in range(len(pred)): + points = pred[n]['points'] + + if not Polygon(points).is_valid or not Polygon(points).is_simple: + continue + + detRect = points + detRects.append(detRect) + detPolPoints.append(points) + if len(gtDontCareRectsNum) > 0: + for dontCareRectNum in gtDontCareRectsNum: + dontCareRect = gtRects[dontCareRectNum] + intersected_area = get_intersection(dontCareRect, detRect) + rdDimensions = Polygon(detRect).area + if (rdDimensions == 0): + precision = 0 + else: + precision = intersected_area / rdDimensions + if (precision > 0.5): + detDontCareRectsNum.append(len(detRects) - 1) + break + + evaluationLog += "DET rectangles: " + str(len(detRects)) + ( + " (" + str(len(detDontCareRectsNum)) + " don't care)\n" + if len(detDontCareRectsNum) > 0 else "\n") + + if len(gtRects) == 0: + recall = 1 + precision = 0 if len(detRects) > 0 else 1 + + if len(detRects) > 0: + #Calculate recall and precision matrixs + outputShape = [len(gtRects), len(detRects)] + recallMat = np.empty(outputShape) + precisionMat = np.empty(outputShape) + gtRectMat = np.zeros(len(gtRects), np.int8) + detRectMat = np.zeros(len(detRects), np.int8) + for gtNum in range(len(gtRects)): + for detNum in range(len(detRects)): + rG = gtRects[gtNum] + rD = detRects[detNum] + intersected_area = get_intersection(rG, rD) + rgDimensions = Polygon(rG).area + rdDimensions = Polygon(rD).area + recallMat[ + gtNum, + detNum] = 0 if rgDimensions == 0 else intersected_area / rgDimensions + precisionMat[ + gtNum, + detNum] = 0 if rdDimensions == 0 else intersected_area / rdDimensions + + # Find one-to-one matches + evaluationLog += "Find one-to-one matches\n" + for gtNum in range(len(gtRects)): + for detNum in range(len(detRects)): + if gtRectMat[gtNum] == 0 and detRectMat[ + detNum] == 0 and gtNum not in gtDontCareRectsNum and detNum not in detDontCareRectsNum: + match = one_to_one_match(gtNum, detNum) + if match is True: + #in deteval we have to make other validation before mark as one-to-one + rG = gtRects[gtNum] + rD = detRects[detNum] + normDist = center_distance(rG, rD) + normDist /= diag(rG) + diag(rD) + normDist *= 2.0 + if normDist < self.ev_param_ind_center_diff_thr: + gtRectMat[gtNum] = 1 + detRectMat[detNum] = 1 + recallAccum += 1.0 + precisionAccum += 1.0 + pairs.append({ + 'gt': gtNum, + 'det': detNum, + 'type': 'OO' + }) + evaluationLog += "Match GT #" + str( + gtNum) + " with Det #" + str(detNum) + "\n" + else: + evaluationLog += "Match Discarded GT #" + str( + gtNum) + " with Det #" + str( + detNum) + " normDist: " + str( + normDist) + " \n" + # Find one-to-many matches + evaluationLog += "Find one-to-many matches\n" + for gtNum in range(len(gtRects)): + if gtNum not in gtDontCareRectsNum: + match, matchesDet = one_to_many_match(gtNum) + if match is True: + gtRectMat[gtNum] = 1 + recallAccum += 1.0 + precisionAccum += len(matchesDet) / ( + 1 + math.log(len(matchesDet))) + pairs.append({ + 'gt': gtNum, + 'det': matchesDet, + 'type': 'OO' if len(matchesDet) == 1 else 'OM' + }) + for detNum in matchesDet: + detRectMat[detNum] = 1 + evaluationLog += "Match GT #" + str( + gtNum) + " with Det #" + str(matchesDet) + "\n" + + # Find many-to-one matches + evaluationLog += "Find many-to-one matches\n" + for detNum in range(len(detRects)): + if detNum not in detDontCareRectsNum: + match, matchesGt = many_to_one_match(detNum) + if match is True: + detRectMat[detNum] = 1 + recallAccum += len(matchesGt) / ( + 1 + math.log(len(matchesGt))) + precisionAccum += 1.0 + pairs.append({ + 'gt': matchesGt, + 'det': detNum, + 'type': 'OO' if len(matchesGt) == 1 else 'MO' + }) + for gtNum in matchesGt: + gtRectMat[gtNum] = 1 + evaluationLog += "Match GT #" + str( + matchesGt) + " with Det #" + str(detNum) + "\n" + + numGtCare = (len(gtRects) - len(gtDontCareRectsNum)) + if numGtCare == 0: + recall = float(1) + precision = float(0) if len(detRects) > 0 else float(1) + else: + recall = float(recallAccum) / numGtCare + precision = float(0) if ( + len(detRects) - len(detDontCareRectsNum) + ) == 0 else float(precisionAccum) / ( + len(detRects) - len(detDontCareRectsNum)) + hmean = 0 if (precision + recall + ) == 0 else 2.0 * precision * recall / ( + precision + recall) + + numGtCare = len(gtRects) - len(gtDontCareRectsNum) + numDetCare = len(detRects) - len(detDontCareRectsNum) + + perSampleMetrics = { + 'precision': precision, + 'recall': recall, + 'hmean': hmean, + 'pairs': pairs, + 'recallMat': [] if len(detRects) > 100 else recallMat.tolist(), + 'precisionMat': [] + if len(detRects) > 100 else precisionMat.tolist(), + 'gtPolPoints': gtPolPoints, + 'detPolPoints': detPolPoints, + 'gtCare': numGtCare, + 'detCare': numDetCare, + 'gtDontCare': gtDontCareRectsNum, + 'detDontCare': detDontCareRectsNum, + 'recallAccum': recallAccum, + 'precisionAccum': precisionAccum, + 'evaluationLog': evaluationLog + } + + return perSampleMetrics + + def combine_results(self, results): + numGt = 0 + numDet = 0 + methodRecallSum = 0 + methodPrecisionSum = 0 + + for result in results: + numGt += result['gtCare'] + numDet += result['detCare'] + methodRecallSum += result['recallAccum'] + methodPrecisionSum += result['precisionAccum'] + + methodRecall = 0 if numGt == 0 else methodRecallSum / numGt + methodPrecision = 0 if numDet == 0 else methodPrecisionSum / numDet + methodHmean = 0 if methodRecall + methodPrecision == 0 else 2 * methodRecall * methodPrecision / ( + methodRecall + methodPrecision) + + methodMetrics = { + 'precision': methodPrecision, + 'recall': methodRecall, + 'hmean': methodHmean + } + + return methodMetrics + + +if __name__ == '__main__': + evaluator = DetectionICDAR2013Evaluator() + gts = [[{ + 'points': [(0, 0), (1, 0), (1, 1), (0, 1)], + 'text': 1234, + 'ignore': False, + }, { + 'points': [(2, 2), (3, 2), (3, 3), (2, 3)], + 'text': 5678, + 'ignore': True, + }]] + preds = [[{ + 'points': [(0.1, 0.1), (1, 0), (1, 1), (0, 1)], + 'text': 123, + 'ignore': False, + }]] + results = [] + for gt, pred in zip(gts, preds): + results.append(evaluator.evaluate_image(gt, pred)) + metrics = evaluator.combine_results(results) + print(metrics) diff --git a/benchmark/PaddleOCR_DBNet/utils/ocr_metric/icdar2015/quad_metric.py b/benchmark/PaddleOCR_DBNet/utils/ocr_metric/icdar2015/quad_metric.py new file mode 100644 index 00000000..e7e403a3 --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/utils/ocr_metric/icdar2015/quad_metric.py @@ -0,0 +1,98 @@ +import numpy as np + +from .detection.iou import DetectionIoUEvaluator + + +class AverageMeter(object): + """Computes and stores the average and current value""" + + def __init__(self): + self.reset() + + def reset(self): + self.val = 0 + self.avg = 0 + self.sum = 0 + self.count = 0 + + def update(self, val, n=1): + self.val = val + self.sum += val * n + self.count += n + self.avg = self.sum / self.count + return self + + +class QuadMetric(): + def __init__(self, is_output_polygon=False): + self.is_output_polygon = is_output_polygon + self.evaluator = DetectionIoUEvaluator( + is_output_polygon=is_output_polygon) + + def measure(self, batch, output, box_thresh=0.6): + ''' + batch: (image, polygons, ignore_tags + batch: a dict produced by dataloaders. + image: tensor of shape (N, C, H, W). + polygons: tensor of shape (N, K, 4, 2), the polygons of objective regions. + ignore_tags: tensor of shape (N, K), indicates whether a region is ignorable or not. + shape: the original shape of images. + filename: the original filenames of images. + output: (polygons, ...) + ''' + results = [] + gt_polyons_batch = batch['text_polys'] + ignore_tags_batch = batch['ignore_tags'] + pred_polygons_batch = np.array(output[0]) + pred_scores_batch = np.array(output[1]) + for polygons, pred_polygons, pred_scores, ignore_tags in zip( + gt_polyons_batch, pred_polygons_batch, pred_scores_batch, + ignore_tags_batch): + gt = [ + dict( + points=np.int64(polygons[i]), ignore=ignore_tags[i]) + for i in range(len(polygons)) + ] + if self.is_output_polygon: + pred = [ + dict(points=pred_polygons[i]) + for i in range(len(pred_polygons)) + ] + else: + pred = [] + # print(pred_polygons.shape) + for i in range(pred_polygons.shape[0]): + if pred_scores[i] >= box_thresh: + # print(pred_polygons[i,:,:].tolist()) + pred.append( + dict(points=pred_polygons[i, :, :].astype(np.int))) + # pred = [dict(points=pred_polygons[i,:,:].tolist()) if pred_scores[i] >= box_thresh for i in range(pred_polygons.shape[0])] + results.append(self.evaluator.evaluate_image(gt, pred)) + return results + + def validate_measure(self, batch, output, box_thresh=0.6): + return self.measure(batch, output, box_thresh) + + def evaluate_measure(self, batch, output): + return self.measure(batch, output), np.linspace( + 0, batch['image'].shape[0]).tolist() + + def gather_measure(self, raw_metrics): + raw_metrics = [ + image_metrics + for batch_metrics in raw_metrics for image_metrics in batch_metrics + ] + + result = self.evaluator.combine_results(raw_metrics) + + precision = AverageMeter() + recall = AverageMeter() + fmeasure = AverageMeter() + + precision.update(result['precision'], n=len(raw_metrics)) + recall.update(result['recall'], n=len(raw_metrics)) + fmeasure_score = 2 * precision.val * recall.val / ( + precision.val + recall.val + 1e-8) + fmeasure.update(fmeasure_score) + + return {'precision': precision, 'recall': recall, 'fmeasure': fmeasure} diff --git a/benchmark/PaddleOCR_DBNet/utils/profiler.py b/benchmark/PaddleOCR_DBNet/utils/profiler.py new file mode 100644 index 00000000..e64afd6a --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/utils/profiler.py @@ -0,0 +1,110 @@ +# copyright (c) 2021 PaddlePaddle Authors. All Rights Reserve. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sys +import paddle + +# A global variable to record the number of calling times for profiler +# functions. It is used to specify the tracing range of training steps. +_profiler_step_id = 0 + +# A global variable to avoid parsing from string every time. +_profiler_options = None + + +class ProfilerOptions(object): + ''' + Use a string to initialize a ProfilerOptions. + The string should be in the format: "key1=value1;key2=value;key3=value3". + For example: + "profile_path=model.profile" + "batch_range=[50, 60]; profile_path=model.profile" + "batch_range=[50, 60]; tracer_option=OpDetail; profile_path=model.profile" + ProfilerOptions supports following key-value pair: + batch_range - a integer list, e.g. [100, 110]. + state - a string, the optional values are 'CPU', 'GPU' or 'All'. + sorted_key - a string, the optional values are 'calls', 'total', + 'max', 'min' or 'ave. + tracer_option - a string, the optional values are 'Default', 'OpDetail', + 'AllOpDetail'. + profile_path - a string, the path to save the serialized profile data, + which can be used to generate a timeline. + exit_on_finished - a boolean. + ''' + + def __init__(self, options_str): + assert isinstance(options_str, str) + + self._options = { + 'batch_range': [10, 20], + 'state': 'All', + 'sorted_key': 'total', + 'tracer_option': 'Default', + 'profile_path': '/tmp/profile', + 'exit_on_finished': True + } + self._parse_from_string(options_str) + + def _parse_from_string(self, options_str): + for kv in options_str.replace(' ', '').split(';'): + key, value = kv.split('=') + if key == 'batch_range': + value_list = value.replace('[', '').replace(']', '').split(',') + value_list = list(map(int, value_list)) + if len(value_list) >= 2 and value_list[0] >= 0 and value_list[ + 1] > value_list[0]: + self._options[key] = value_list + elif key == 'exit_on_finished': + self._options[key] = value.lower() in ("yes", "true", "t", "1") + elif key in [ + 'state', 'sorted_key', 'tracer_option', 'profile_path' + ]: + self._options[key] = value + + def __getitem__(self, name): + if self._options.get(name, None) is None: + raise ValueError( + "ProfilerOptions does not have an option named %s." % name) + return self._options[name] + + +def add_profiler_step(options_str=None): + ''' + Enable the operator-level timing using PaddlePaddle's profiler. + The profiler uses a independent variable to count the profiler steps. + One call of this function is treated as a profiler step. + + Args: + profiler_options - a string to initialize the ProfilerOptions. + Default is None, and the profiler is disabled. + ''' + if options_str is None: + return + + global _profiler_step_id + global _profiler_options + + if _profiler_options is None: + _profiler_options = ProfilerOptions(options_str) + + if _profiler_step_id == _profiler_options['batch_range'][0]: + paddle.utils.profiler.start_profiler(_profiler_options['state'], + _profiler_options['tracer_option']) + elif _profiler_step_id == _profiler_options['batch_range'][1]: + paddle.utils.profiler.stop_profiler(_profiler_options['sorted_key'], + _profiler_options['profile_path']) + if _profiler_options['exit_on_finished']: + sys.exit(0) + + _profiler_step_id += 1 diff --git a/benchmark/PaddleOCR_DBNet/utils/schedulers.py b/benchmark/PaddleOCR_DBNet/utils/schedulers.py new file mode 100644 index 00000000..1b6fb7d2 --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/utils/schedulers.py @@ -0,0 +1,64 @@ +from paddle.optimizer import lr +import logging +__all__ = ['Polynomial'] + + +class Polynomial(object): + """ + Polynomial learning rate decay + Args: + learning_rate (float): The initial learning rate. It is a python float number. + epochs(int): The decay epoch size. It determines the decay cycle, when by_epoch is set to true, it will change to epochs=epochs*step_each_epoch. + step_each_epoch: all steps in each epoch. + end_lr(float, optional): The minimum final learning rate. Default: 0.0001. + power(float, optional): Power of polynomial. Default: 1.0. + warmup_epoch(int): The epoch numbers for LinearWarmup. Default: 0, , when by_epoch is set to true, it will change to warmup_epoch=warmup_epoch*step_each_epoch. + warmup_start_lr(float): Initial learning rate of warm up. Default: 0.0. + last_epoch (int, optional): The index of last epoch. Can be set to restart training. Default: -1, means initial learning rate. + by_epoch: Whether the set parameter is based on epoch or iter, when set to true,, epochs and warmup_epoch will be automatically multiplied by step_each_epoch. Default: True + """ + + def __init__(self, + learning_rate, + epochs, + step_each_epoch, + end_lr=0.0, + power=1.0, + warmup_epoch=0, + warmup_start_lr=0.0, + last_epoch=-1, + by_epoch=True, + **kwargs): + super().__init__() + if warmup_epoch >= epochs: + msg = f"When using warm up, the value of \"epochs\" must be greater than value of \"Optimizer.lr.warmup_epoch\". The value of \"Optimizer.lr.warmup_epoch\" has been set to {epochs}." + logging.warning(msg) + warmup_epoch = epochs + self.learning_rate = learning_rate + self.epochs = epochs + self.end_lr = end_lr + self.power = power + self.last_epoch = last_epoch + self.warmup_epoch = warmup_epoch + self.warmup_start_lr = warmup_start_lr + + if by_epoch: + self.epochs *= step_each_epoch + self.warmup_epoch = int(self.warmup_epoch * step_each_epoch) + + def __call__(self): + learning_rate = lr.PolynomialDecay( + learning_rate=self.learning_rate, + decay_steps=self.epochs, + end_lr=self.end_lr, + power=self.power, + last_epoch=self. + last_epoch) if self.epochs > 0 else self.learning_rate + if self.warmup_epoch > 0: + learning_rate = lr.LinearWarmup( + learning_rate=learning_rate, + warmup_steps=self.warmup_epoch, + start_lr=self.warmup_start_lr, + end_lr=self.learning_rate, + last_epoch=self.last_epoch) + return learning_rate diff --git a/benchmark/PaddleOCR_DBNet/utils/util.py b/benchmark/PaddleOCR_DBNet/utils/util.py new file mode 100644 index 00000000..39bae764 --- /dev/null +++ b/benchmark/PaddleOCR_DBNet/utils/util.py @@ -0,0 +1,367 @@ +# -*- coding: utf-8 -*- +# @Time : 2019/8/23 21:59 +# @Author : zhoujun +import json +import pathlib +import time +import os +import glob +import cv2 +import yaml +from typing import Mapping +import matplotlib.pyplot as plt +import numpy as np + +from argparse import ArgumentParser, RawDescriptionHelpFormatter + + +def _check_image_file(path): + img_end = {'jpg', 'bmp', 'png', 'jpeg', 'rgb', 'tif', 'tiff', 'gif', 'pdf'} + return any([path.lower().endswith(e) for e in img_end]) + + +def get_image_file_list(img_file): + imgs_lists = [] + if img_file is None or not os.path.exists(img_file): + raise Exception("not found any img file in {}".format(img_file)) + + img_end = {'jpg', 'bmp', 'png', 'jpeg', 'rgb', 'tif', 'tiff', 'gif', 'pdf'} + if os.path.isfile(img_file) and _check_image_file(img_file): + imgs_lists.append(img_file) + elif os.path.isdir(img_file): + for single_file in os.listdir(img_file): + file_path = os.path.join(img_file, single_file) + if os.path.isfile(file_path) and _check_image_file(file_path): + imgs_lists.append(file_path) + if len(imgs_lists) == 0: + raise Exception("not found any img file in {}".format(img_file)) + imgs_lists = sorted(imgs_lists) + return imgs_lists + + +def setup_logger(log_file_path: str=None): + import logging + logging._warn_preinit_stderr = 0 + logger = logging.getLogger('DBNet.paddle') + formatter = logging.Formatter( + '%(asctime)s %(name)s %(levelname)s: %(message)s') + ch = logging.StreamHandler() + ch.setFormatter(formatter) + logger.addHandler(ch) + if log_file_path is not None: + file_handle = logging.FileHandler(log_file_path) + file_handle.setFormatter(formatter) + logger.addHandler(file_handle) + logger.setLevel(logging.DEBUG) + return logger + + +# --exeTime +def exe_time(func): + def newFunc(*args, **args2): + t0 = time.time() + back = func(*args, **args2) + print("{} cost {:.3f}s".format(func.__name__, time.time() - t0)) + return back + + return newFunc + + +def load(file_path: str): + file_path = pathlib.Path(file_path) + func_dict = {'.txt': _load_txt, '.json': _load_json, '.list': _load_txt} + assert file_path.suffix in func_dict + return func_dict[file_path.suffix](file_path) + + +def _load_txt(file_path: str): + with open(file_path, 'r', encoding='utf8') as f: + content = [ + x.strip().strip('\ufeff').strip('\xef\xbb\xbf') + for x in f.readlines() + ] + return content + + +def _load_json(file_path: str): + with open(file_path, 'r', encoding='utf8') as f: + content = json.load(f) + return content + + +def save(data, file_path): + file_path = pathlib.Path(file_path) + func_dict = {'.txt': _save_txt, '.json': _save_json} + assert file_path.suffix in func_dict + return func_dict[file_path.suffix](data, file_path) + + +def _save_txt(data, file_path): + """ + 将一个list的数组写入txt文件里 + :param data: + :param file_path: + :return: + """ + if not isinstance(data, list): + data = [data] + with open(file_path, mode='w', encoding='utf8') as f: + f.write('\n'.join(data)) + + +def _save_json(data, file_path): + with open(file_path, 'w', encoding='utf-8') as json_file: + json.dump(data, json_file, ensure_ascii=False, indent=4) + + +def show_img(imgs: np.ndarray, title='img'): + color = (len(imgs.shape) == 3 and imgs.shape[-1] == 3) + imgs = np.expand_dims(imgs, axis=0) + for i, img in enumerate(imgs): + plt.figure() + plt.title('{}_{}'.format(title, i)) + plt.imshow(img, cmap=None if color else 'gray') + plt.show() + + +def draw_bbox(img_path, result, color=(255, 0, 0), thickness=2): + if isinstance(img_path, str): + img_path = cv2.imread(img_path) + # img_path = cv2.cvtColor(img_path, cv2.COLOR_BGR2RGB) + img_path = img_path.copy() + for point in result: + point = point.astype(int) + cv2.polylines(img_path, [point], True, color, thickness) + return img_path + + +def cal_text_score(texts, + gt_texts, + training_masks, + running_metric_text, + thred=0.5): + training_masks = training_masks.numpy() + pred_text = texts.numpy() * training_masks + pred_text[pred_text <= thred] = 0 + pred_text[pred_text > thred] = 1 + pred_text = pred_text.astype(np.int32) + gt_text = gt_texts.numpy() * training_masks + gt_text = gt_text.astype(np.int32) + running_metric_text.update(gt_text, pred_text) + score_text, _ = running_metric_text.get_scores() + return score_text + + +def order_points_clockwise(pts): + rect = np.zeros((4, 2), dtype="float32") + s = pts.sum(axis=1) + rect[0] = pts[np.argmin(s)] + rect[2] = pts[np.argmax(s)] + diff = np.diff(pts, axis=1) + rect[1] = pts[np.argmin(diff)] + rect[3] = pts[np.argmax(diff)] + return rect + + +def order_points_clockwise_list(pts): + pts = pts.tolist() + pts.sort(key=lambda x: (x[1], x[0])) + pts[:2] = sorted(pts[:2], key=lambda x: x[0]) + pts[2:] = sorted(pts[2:], key=lambda x: -x[0]) + pts = np.array(pts) + return pts + + +def get_datalist(train_data_path): + """ + 获取训练和验证的数据list + :param train_data_path: 训练的dataset文件列表,每个文件内以如下格式存储 ‘path/to/img\tlabel’ + :return: + """ + train_data = [] + for p in train_data_path: + with open(p, 'r', encoding='utf-8') as f: + for line in f.readlines(): + line = line.strip('\n').replace('.jpg ', '.jpg\t').split('\t') + if len(line) > 1: + img_path = pathlib.Path(line[0].strip(' ')) + label_path = pathlib.Path(line[1].strip(' ')) + if img_path.exists() and img_path.stat( + ).st_size > 0 and label_path.exists() and label_path.stat( + ).st_size > 0: + train_data.append((str(img_path), str(label_path))) + return train_data + + +def save_result(result_path, box_list, score_list, is_output_polygon): + if is_output_polygon: + with open(result_path, 'wt') as res: + for i, box in enumerate(box_list): + box = box.reshape(-1).tolist() + result = ",".join([str(int(x)) for x in box]) + score = score_list[i] + res.write(result + ',' + str(score) + "\n") + else: + with open(result_path, 'wt') as res: + for i, box in enumerate(box_list): + score = score_list[i] + box = box.reshape(-1).tolist() + result = ",".join([str(int(x)) for x in box]) + res.write(result + ',' + str(score) + "\n") + + +def expand_polygon(polygon): + """ + 对只有一个字符的框进行扩充 + """ + (x, y), (w, h), angle = cv2.minAreaRect(np.float32(polygon)) + if angle < -45: + w, h = h, w + angle += 90 + new_w = w + h + box = ((x, y), (new_w, h), angle) + points = cv2.boxPoints(box) + return order_points_clockwise(points) + + +def _merge_dict(config, merge_dct): + """ Recursive dict merge. Inspired by :meth:``dict.update()``, instead of + updating only top-level keys, dict_merge recurses down into dicts nested + to an arbitrary depth, updating keys. The ``merge_dct`` is merged into + ``dct``. + Args: + config: dict onto which the merge is executed + merge_dct: dct merged into config + Returns: dct + """ + for key, value in merge_dct.items(): + sub_keys = key.split('.') + key = sub_keys[0] + if key in config and len(sub_keys) > 1: + _merge_dict(config[key], {'.'.join(sub_keys[1:]): value}) + elif key in config and isinstance(config[key], dict) and isinstance( + value, Mapping): + _merge_dict(config[key], value) + else: + config[key] = value + return config + + +def print_dict(cfg, print_func=print, delimiter=0): + """ + Recursively visualize a dict and + indenting acrrording by the relationship of keys. + """ + for k, v in sorted(cfg.items()): + if isinstance(v, dict): + print_func("{}{} : ".format(delimiter * " ", str(k))) + print_dict(v, print_func, delimiter + 4) + elif isinstance(v, list) and len(v) >= 1 and isinstance(v[0], dict): + print_func("{}{} : ".format(delimiter * " ", str(k))) + for value in v: + print_dict(value, print_func, delimiter + 4) + else: + print_func("{}{} : {}".format(delimiter * " ", k, v)) + + +class Config(object): + def __init__(self, config_path, BASE_KEY='base'): + self.BASE_KEY = BASE_KEY + self.cfg = self._load_config_with_base(config_path) + + def _load_config_with_base(self, file_path): + """ + Load config from file. + Args: + file_path (str): Path of the config file to be loaded. + Returns: global config + """ + _, ext = os.path.splitext(file_path) + assert ext in ['.yml', '.yaml'], "only support yaml files for now" + + with open(file_path) as f: + file_cfg = yaml.load(f, Loader=yaml.Loader) + + # NOTE: cfgs outside have higher priority than cfgs in _BASE_ + if self.BASE_KEY in file_cfg: + all_base_cfg = dict() + base_ymls = list(file_cfg[self.BASE_KEY]) + for base_yml in base_ymls: + with open(base_yml) as f: + base_cfg = self._load_config_with_base(base_yml) + all_base_cfg = _merge_dict(all_base_cfg, base_cfg) + + del file_cfg[self.BASE_KEY] + file_cfg = _merge_dict(all_base_cfg, file_cfg) + file_cfg['filename'] = os.path.splitext(os.path.split(file_path)[-1])[0] + return file_cfg + + def merge_dict(self, args): + self.cfg = _merge_dict(self.cfg, args) + + def print_cfg(self, print_func=print): + """ + Recursively visualize a dict and + indenting acrrording by the relationship of keys. + """ + print_func('----------- Config -----------') + print_dict(self.cfg, print_func) + print_func('---------------------------------------------') + + def save(self, p): + with open(p, 'w') as f: + yaml.dump( + dict(self.cfg), f, default_flow_style=False, sort_keys=False) + + +class ArgsParser(ArgumentParser): + def __init__(self): + super(ArgsParser, self).__init__( + formatter_class=RawDescriptionHelpFormatter) + self.add_argument( + "-c", "--config_file", help="configuration file to use") + self.add_argument( + "-o", "--opt", nargs='*', help="set configuration options") + self.add_argument( + '-p', + '--profiler_options', + type=str, + default=None, + help='The option of profiler, which should be in format ' \ + '\"key1=value1;key2=value2;key3=value3\".' + ) + + def parse_args(self, argv=None): + args = super(ArgsParser, self).parse_args(argv) + assert args.config_file is not None, \ + "Please specify --config_file=configure_file_path." + args.opt = self._parse_opt(args.opt) + return args + + def _parse_opt(self, opts): + config = {} + if not opts: + return config + for s in opts: + s = s.strip() + k, v = s.split('=', 1) + if '.' not in k: + config[k] = yaml.load(v, Loader=yaml.Loader) + else: + keys = k.split('.') + if keys[0] not in config: + config[keys[0]] = {} + cur = config[keys[0]] + for idx, key in enumerate(keys[1:]): + if idx == len(keys) - 2: + cur[key] = yaml.load(v, Loader=yaml.Loader) + else: + cur[key] = {} + cur = cur[key] + return config + + +if __name__ == '__main__': + img = np.zeros((1, 3, 640, 640)) + show_img(img[0][0]) + plt.show() -- GitLab