@camstack/addon-pipeline 1.2.5 → 1.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/audio-analyzer/index.js +1 -1
- package/dist/audio-analyzer/index.mjs +1 -1
- package/dist/detection-pipeline/index.js +499 -30
- package/dist/detection-pipeline/index.mjs +493 -24
- package/dist/{dist-ZB-B8UrH.mjs → dist-BwyGTnMu.mjs} +306 -3
- package/dist/{dist-CtQsET1Q.js → dist-CAT3VF7A.js} +306 -3
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/pipeline-runner/index.js +30 -15
- package/dist/pipeline-runner/index.mjs +30 -15
- package/dist/recorder/index.js +1 -1
- package/dist/recorder/index.mjs +1 -1
- package/dist/{step-definitions-DrRnEQPM.js → step-definitions-BPEExRug.js} +70 -2
- package/dist/{step-definitions-BKJI5CXI.mjs → step-definitions-DN1obXS7.mjs} +70 -2
- package/dist/stream-broker/_stub.js +1 -1
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DPvS4Ex7.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BtN6sZcL.mjs} +1 -1
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-D34ivtL2.mjs +26 -0
- package/dist/stream-broker/{hostInit-CG0IeE_e.mjs → hostInit-BIZvt2bk.mjs} +1 -1
- package/dist/stream-broker/index.js +35 -6
- package/dist/stream-broker/index.mjs +35 -6
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-C-rkQ2rg.js → MaskShapeCanvas-DI4BY7W2-3uL2OmvC.js} +1 -1
- package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-BtbCLveu.js → MotionZonesSettings-NcxxQN8r-JkIkJTeI.js} +1 -1
- package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-C9CEgSlA.js → PrivacyMaskSettings-APgPLF7p-AKGABxQk.js} +1 -1
- package/embed-dist/assets/{index-CgJ1frQY.js → index-DfcKIHhA.js} +14 -14
- package/embed-dist/index.html +1 -1
- package/package.json +1 -1
- package/python/postprocessors/ctc.py +228 -4
- package/python/postprocessors/test_ctc.py +312 -0
- package/python/postprocessors/testdata/ctc_degenerate_lowres_logits.npy.gz.b64 +1 -0
- package/python/postprocessors/testdata/ctc_dwro309_logits.npy.gz.b64 +1 -0
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CQC_f4Yv.mjs +0 -26
package/embed-dist/index.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<title>CamStack Embed</title>
|
|
7
|
-
<script type="module" crossorigin src="./assets/index-
|
|
7
|
+
<script type="module" crossorigin src="./assets/index-DfcKIHhA.js"></script>
|
|
8
8
|
<link rel="stylesheet" crossorigin href="./assets/index-Dv5H-32C.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/addon-pipeline",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.7",
|
|
4
4
|
"description": "CamStack Pipeline bundle — runner, detection, motion, audio + stream broker. Multi-entry npm package shipping pipeline addons under a single bundle.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"camstack",
|
|
@@ -3,12 +3,203 @@
|
|
|
3
3
|
Used for: vgg_english_g2 (plate OCR).
|
|
4
4
|
|
|
5
5
|
Input: [1, seqLen, numChars] logits
|
|
6
|
-
Output: {"kind": "text", "text": "ABC123", "confidence": 0.95}
|
|
6
|
+
Output: {"kind": "text", "text": "ABC123", "confidence": 0.95, "formatValid": true}
|
|
7
7
|
|
|
8
8
|
Algorithm: argmax per timestep → collapse consecutive duplicates → remove blank (index 0) → join
|
|
9
|
+
|
|
10
|
+
Plausibility gate
|
|
11
|
+
-----------------
|
|
12
|
+
When the recognizer is fed a too-low-resolution / heavily-blurred plate crop
|
|
13
|
+
its logit map collapses toward the CTC blank and greedy decode emits a spurious
|
|
14
|
+
1–2 character read (e.g. a lone "A") at whatever confidence the single surviving
|
|
15
|
+
timestep carries. That noise was being stored verbatim as a "plate" label. The
|
|
16
|
+
charset + greedy decode themselves are correct — this only rejects a result too
|
|
17
|
+
short to be a real plate (`minTextLength`, non-space chars) or below a confidence
|
|
18
|
+
floor (`minTextConfidence`), returning empty text so no garbage label is stored.
|
|
19
|
+
Real plates read at 5+ chars / conf ~0.86–1.0, so the conservative defaults never
|
|
20
|
+
suppress a genuine read; both are config-overridable for per-camera tuning.
|
|
21
|
+
|
|
22
|
+
Format-aware rescoring (post-hoc)
|
|
23
|
+
---------------------------------
|
|
24
|
+
After the plausibility gate, the greedy read is validated against the active
|
|
25
|
+
region's plate grammar (`plateRegion`, default 'DE'; 'off' disables). If the raw
|
|
26
|
+
letter/digit sequence already matches, it is accepted as-is (`formatValid: true`).
|
|
27
|
+
If it does NOT match, candidate reads are generated by substituting classic OCR
|
|
28
|
+
confusables (0↔O, 1↔I, 8↔B, 5↔S, 2↔Z, 6↔G, 4↔A) position-by-position; each
|
|
29
|
+
candidate is scored by the mean per-character probability and the highest-scoring
|
|
30
|
+
GRAMMAR-CONFORMING candidate replaces the read (`formatValid: true`). If no
|
|
31
|
+
candidate matches, the original greedy read is kept verbatim (`formatValid:
|
|
32
|
+
false`) — validation NEVER drops a plausible read, it only fixes or annotates it.
|
|
33
|
+
|
|
34
|
+
Per-character probability approximation
|
|
35
|
+
---------------------------------------
|
|
36
|
+
The greedy CTC path collapses consecutive-duplicate argmax timesteps; for each
|
|
37
|
+
emitted output character we retain the single timestep `t` at which its run
|
|
38
|
+
began. A candidate's per-character probability is then approximated by the
|
|
39
|
+
softmax at that ONE aligned timestep — for an unchanged position the original
|
|
40
|
+
argmax probability, and for a substituted position `probs[t, altIndex]` (the
|
|
41
|
+
real softmax mass the confusable already carried at that timestep). This is an
|
|
42
|
+
approximation: it does not re-run a full CTC forward alignment for the candidate
|
|
43
|
+
string (which could re-collapse differently). For single-character confusable
|
|
44
|
+
swaps it is a faithful, cheap proxy — the timestep that produced the original
|
|
45
|
+
glyph is exactly where its confusable competes for probability.
|
|
9
46
|
"""
|
|
47
|
+
import itertools
|
|
48
|
+
import re
|
|
49
|
+
|
|
10
50
|
import numpy as np
|
|
11
51
|
|
|
52
|
+
# Shortest plausible plate (non-space chars). No real plate is 1–2 chars, so
|
|
53
|
+
# this only rejects the degenerate near-all-blank collapse; kept conservative to
|
|
54
|
+
# never drop a genuine short/vanity plate.
|
|
55
|
+
DEFAULT_MIN_TEXT_LENGTH = 3
|
|
56
|
+
# Confidence floor (0.0 = disabled by default — character count is the reliable
|
|
57
|
+
# discriminator for the degenerate collapse; the "A"@1.0 case is caught by
|
|
58
|
+
# length, not confidence). Available for per-camera tuning via config.
|
|
59
|
+
DEFAULT_MIN_TEXT_CONFIDENCE = 0.0
|
|
60
|
+
# Product-level default plate grammar region — supplied to this postprocessor by
|
|
61
|
+
# the TS step config (`plateRegion`, see step-definitions.ts). When the key is
|
|
62
|
+
# ABSENT from `config` the postprocessor falls back to 'off' (no rescoring) so a
|
|
63
|
+
# bare / pre-plumbing call is byte-identical to the historical decode behaviour;
|
|
64
|
+
# the DE default only takes effect once the config plumbing supplies it.
|
|
65
|
+
DEFAULT_PLATE_REGION = "DE"
|
|
66
|
+
# Fallback when `plateRegion` is absent from config (backward-compatible: no
|
|
67
|
+
# rescoring, no formatValid annotation).
|
|
68
|
+
ABSENT_PLATE_REGION = "off"
|
|
69
|
+
# Cap on the number of positions that may simultaneously carry a confusable
|
|
70
|
+
# alternative. Each substitutable position doubles the candidate count, so the
|
|
71
|
+
# worst case is 2**MAX_SUBST_POSITIONS candidates. Plates are ≤8 glyphs so this
|
|
72
|
+
# is rarely binding, but the cap guarantees a bounded search (≤256 candidates)
|
|
73
|
+
# even on a pathological long read: when more positions than this have a
|
|
74
|
+
# confusable, only the LOWEST-confidence positions (the most likely misreads)
|
|
75
|
+
# stay substitutable; the rest are locked to their greedy glyph.
|
|
76
|
+
MAX_SUBST_POSITIONS = 8
|
|
77
|
+
|
|
78
|
+
# Classic OCR confusable pairs, bidirectional. Maps a glyph to the alternates it
|
|
79
|
+
# is commonly mistaken for/against. Used ONLY to repair a read that fails the
|
|
80
|
+
# grammar — never to alter a read that already conforms.
|
|
81
|
+
CONFUSABLES: dict[str, tuple[str, ...]] = {
|
|
82
|
+
"0": ("O",),
|
|
83
|
+
"O": ("0",),
|
|
84
|
+
"1": ("I",),
|
|
85
|
+
"I": ("1",),
|
|
86
|
+
"8": ("B",),
|
|
87
|
+
"B": ("8",),
|
|
88
|
+
"5": ("S",),
|
|
89
|
+
"S": ("5",),
|
|
90
|
+
"2": ("Z",),
|
|
91
|
+
"Z": ("2",),
|
|
92
|
+
"6": ("G",),
|
|
93
|
+
"G": ("6",),
|
|
94
|
+
"4": ("A",),
|
|
95
|
+
"A": ("4",),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
# Region plate grammars, keyed by ISO-3166 alpha-2 code. Each pattern validates
|
|
99
|
+
# the RAW letter/digit sequence the OCR emits (no separators — the recognizer
|
|
100
|
+
# never outputs the dash/space of the physical plate). Ready for future
|
|
101
|
+
# countries ('IT', 'FR', …); 'off' (handled before lookup) disables rescoring.
|
|
102
|
+
#
|
|
103
|
+
# GERMANY ('DE'): district code (1–3 letters) + 1–2 letters + 1–4 digits +
|
|
104
|
+
# optional E (electric) / H (historic) suffix; ≤8 glyphs excluding separators.
|
|
105
|
+
# Separators are optional in the pattern for robustness, though the OCR emits
|
|
106
|
+
# none. The letter-block split (district vs. series) is inherently ambiguous on
|
|
107
|
+
# the raw string, but that does not matter — the regex validates the whole
|
|
108
|
+
# sequence, not the split.
|
|
109
|
+
PLATE_GRAMMARS: dict[str, str] = {
|
|
110
|
+
"DE": r"^[A-ZÄÖÜ]{1,3}[- ]?[A-Z]{1,2}[- ]?[0-9]{1,4}[EH]?$",
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _matches_grammar(text: str, pattern) -> bool:
|
|
115
|
+
return pattern.match(text) is not None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _rescore_for_grammar(
|
|
119
|
+
text: str,
|
|
120
|
+
char_scores: list[float],
|
|
121
|
+
char_timesteps: list[int],
|
|
122
|
+
probs: np.ndarray,
|
|
123
|
+
charset: list[str],
|
|
124
|
+
pattern,
|
|
125
|
+
) -> tuple[str, bool]:
|
|
126
|
+
"""Return (repaired_or_original_text, format_valid).
|
|
127
|
+
|
|
128
|
+
If `text` already matches `pattern`, return it unchanged with True.
|
|
129
|
+
Otherwise enumerate confusable substitutions (bounded), score each candidate
|
|
130
|
+
by mean per-character probability, and return the highest-scoring candidate
|
|
131
|
+
that matches the grammar. If none match, return the original text with False.
|
|
132
|
+
"""
|
|
133
|
+
if _matches_grammar(text, pattern):
|
|
134
|
+
return text, True
|
|
135
|
+
|
|
136
|
+
chars = list(text)
|
|
137
|
+
n = len(chars)
|
|
138
|
+
|
|
139
|
+
# Reverse charset lookup (glyph → first index) so a substituted glyph's
|
|
140
|
+
# softmax mass can be read from the aligned timestep. Built once per call.
|
|
141
|
+
char_to_index: dict[str, int] = {}
|
|
142
|
+
for idx, glyph in enumerate(charset):
|
|
143
|
+
if glyph and glyph not in char_to_index:
|
|
144
|
+
char_to_index[glyph] = idx
|
|
145
|
+
|
|
146
|
+
# Which positions carry a usable confusable alternative (both the glyph has a
|
|
147
|
+
# mapping AND the alternate is representable in this charset so its timestep
|
|
148
|
+
# probability can be looked up).
|
|
149
|
+
subst_positions: list[int] = []
|
|
150
|
+
for i, ch in enumerate(chars):
|
|
151
|
+
alts = CONFUSABLES.get(ch)
|
|
152
|
+
if not alts:
|
|
153
|
+
continue
|
|
154
|
+
if any(alt in char_to_index for alt in alts):
|
|
155
|
+
subst_positions.append(i)
|
|
156
|
+
|
|
157
|
+
if not subst_positions:
|
|
158
|
+
return text, False
|
|
159
|
+
|
|
160
|
+
# Bound the search: keep only the MAX_SUBST_POSITIONS lowest-confidence
|
|
161
|
+
# positions substitutable (most likely misreads); lock the rest.
|
|
162
|
+
if len(subst_positions) > MAX_SUBST_POSITIONS:
|
|
163
|
+
subst_positions.sort(key=lambda i: char_scores[i])
|
|
164
|
+
subst_positions = sorted(subst_positions[:MAX_SUBST_POSITIONS])
|
|
165
|
+
|
|
166
|
+
subst_set = set(subst_positions)
|
|
167
|
+
|
|
168
|
+
# Per-position option list: original glyph, plus any representable confusables
|
|
169
|
+
# (only for substitutable positions). itertools.product enumerates the grid.
|
|
170
|
+
options: list[list[str]] = []
|
|
171
|
+
for i, ch in enumerate(chars):
|
|
172
|
+
if i in subst_set:
|
|
173
|
+
alts = [a for a in CONFUSABLES.get(ch, ()) if a in char_to_index]
|
|
174
|
+
options.append([ch, *alts])
|
|
175
|
+
else:
|
|
176
|
+
options.append([ch])
|
|
177
|
+
|
|
178
|
+
def _prob_at(i: int, glyph: str) -> float:
|
|
179
|
+
if glyph == chars[i]:
|
|
180
|
+
return char_scores[i]
|
|
181
|
+
idx = char_to_index.get(glyph)
|
|
182
|
+
if idx is None:
|
|
183
|
+
return 0.0
|
|
184
|
+
return float(probs[char_timesteps[i], idx])
|
|
185
|
+
|
|
186
|
+
best_text: str | None = None
|
|
187
|
+
best_score = -1.0
|
|
188
|
+
for combo in itertools.product(*options):
|
|
189
|
+
candidate = "".join(combo)
|
|
190
|
+
if candidate == text:
|
|
191
|
+
continue # already known non-matching (checked above)
|
|
192
|
+
if not _matches_grammar(candidate, pattern):
|
|
193
|
+
continue
|
|
194
|
+
score = sum(_prob_at(i, combo[i]) for i in range(n)) / n if n else 0.0
|
|
195
|
+
if score > best_score:
|
|
196
|
+
best_score = score
|
|
197
|
+
best_text = candidate
|
|
198
|
+
|
|
199
|
+
if best_text is not None:
|
|
200
|
+
return best_text, True
|
|
201
|
+
return text, False
|
|
202
|
+
|
|
12
203
|
|
|
13
204
|
def postprocess_ctc(
|
|
14
205
|
predictions: dict,
|
|
@@ -39,30 +230,63 @@ def postprocess_ctc(
|
|
|
39
230
|
best_indices = np.argmax(probs, axis=1) # [T]
|
|
40
231
|
best_scores = probs[np.arange(seq_len), best_indices] # [T]
|
|
41
232
|
|
|
42
|
-
# Collapse consecutive duplicates
|
|
233
|
+
# Collapse consecutive duplicates (retain the timestep each run began at so
|
|
234
|
+
# the rescorer can read a confusable's softmax mass at that aligned step).
|
|
43
235
|
collapsed = []
|
|
44
236
|
collapsed_scores = []
|
|
237
|
+
collapsed_timesteps = []
|
|
45
238
|
for i, idx in enumerate(best_indices):
|
|
46
239
|
if i == 0 or idx != best_indices[i - 1]:
|
|
47
240
|
collapsed.append(int(idx))
|
|
48
241
|
collapsed_scores.append(float(best_scores[i]))
|
|
242
|
+
collapsed_timesteps.append(i)
|
|
49
243
|
|
|
50
244
|
# Remove blank (index 0) and join
|
|
51
245
|
chars = []
|
|
52
246
|
scores = []
|
|
53
|
-
|
|
247
|
+
char_timesteps = []
|
|
248
|
+
for idx, score, t in zip(collapsed, collapsed_scores, collapsed_timesteps):
|
|
54
249
|
if idx != 0:
|
|
55
250
|
if idx < len(charset):
|
|
56
251
|
chars.append(charset[idx])
|
|
57
252
|
else:
|
|
58
253
|
chars.append(chr(idx))
|
|
59
254
|
scores.append(score)
|
|
255
|
+
char_timesteps.append(t)
|
|
60
256
|
|
|
61
257
|
text = "".join(chars)
|
|
62
258
|
confidence = float(np.mean(scores)) if scores else 0.0
|
|
63
259
|
|
|
64
|
-
|
|
260
|
+
# Plausibility gate — suppress the degenerate near-all-blank collapse so
|
|
261
|
+
# low-resolution noise is never stored as a plate label. Count non-space
|
|
262
|
+
# characters ("DW RO 309" → 7) so word-gaps don't inflate the length.
|
|
263
|
+
min_len = int(config.get("minTextLength", DEFAULT_MIN_TEXT_LENGTH))
|
|
264
|
+
min_conf = float(config.get("minTextConfidence", DEFAULT_MIN_TEXT_CONFIDENCE))
|
|
265
|
+
alnum_count = sum(1 for c in text if not c.isspace())
|
|
266
|
+
if alnum_count < min_len or confidence < min_conf:
|
|
267
|
+
return {
|
|
268
|
+
"kind": "text",
|
|
269
|
+
"text": "",
|
|
270
|
+
"confidence": round(confidence, 4),
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
result = {
|
|
65
274
|
"kind": "text",
|
|
66
275
|
"text": text,
|
|
67
276
|
"confidence": round(confidence, 4),
|
|
68
277
|
}
|
|
278
|
+
|
|
279
|
+
# Post-hoc format validation + confusable rescoring. 'off' disables it
|
|
280
|
+
# entirely (no grammar lookup, no formatValid annotation).
|
|
281
|
+
region = str(config.get("plateRegion", ABSENT_PLATE_REGION))
|
|
282
|
+
if region != "off":
|
|
283
|
+
pattern_src = PLATE_GRAMMARS.get(region)
|
|
284
|
+
if pattern_src is not None:
|
|
285
|
+
pattern = re.compile(pattern_src)
|
|
286
|
+
fixed_text, format_valid = _rescore_for_grammar(
|
|
287
|
+
text, scores, char_timesteps, probs, list(charset), pattern
|
|
288
|
+
)
|
|
289
|
+
result["text"] = fixed_text
|
|
290
|
+
result["formatValid"] = format_valid
|
|
291
|
+
|
|
292
|
+
return result
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""Tests for the CTC (plate-OCR) postprocessor.
|
|
2
|
+
|
|
3
|
+
Locks in the two properties that keep license-plate recognition correct:
|
|
4
|
+
|
|
5
|
+
1. **Decode + charset calibration** — greedy argmax → collapse consecutive
|
|
6
|
+
duplicates → drop the CTC blank → map through the `vgg_english_g2`
|
|
7
|
+
``VGG_G2_CHARSET`` (index 0 blank, digits 1–10, symbols 11–43, one extra
|
|
8
|
+
symbol slot at 44, letters A–Z/a–z at 45–96). This charset alignment is the
|
|
9
|
+
empirically-calibrated off-by-one fix (commit fe5f34b4c). A regression here
|
|
10
|
+
silently returns systematically wrong letters (e.g. "DWRO" → "EXSP").
|
|
11
|
+
|
|
12
|
+
2. **Plausibility gate** — a too-low-resolution / blurred plate collapses the
|
|
13
|
+
logit map toward the blank and greedy decode emits a spurious 1–2 char read;
|
|
14
|
+
the gate returns empty text so noise is never stored as a plate label. The
|
|
15
|
+
charset/decode are proven correct via captured REAL logits from the ONNX
|
|
16
|
+
model on crisp synthetic text (no model needed at test time).
|
|
17
|
+
"""
|
|
18
|
+
import base64
|
|
19
|
+
import gzip
|
|
20
|
+
import io
|
|
21
|
+
import os
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
|
|
25
|
+
from ctc import (
|
|
26
|
+
DEFAULT_MIN_TEXT_LENGTH,
|
|
27
|
+
MAX_SUBST_POSITIONS,
|
|
28
|
+
postprocess_ctc,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# VGG_G2_CHARSET — mirrors packages/.../registry/step-definitions.ts. The TS
|
|
33
|
+
# copy is the runtime source of truth (passed in via config); this local build
|
|
34
|
+
# lets the decode be tested hermetically. `test_charset_structure` locks the
|
|
35
|
+
# exact index→glyph contract so the two can't silently drift.
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
VGG_G2_CHARSET = (
|
|
38
|
+
[""]
|
|
39
|
+
+ list("0123456789")
|
|
40
|
+
+ list("!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~ ")
|
|
41
|
+
+ [" "]
|
|
42
|
+
+ list("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
TESTDATA = os.path.join(os.path.dirname(__file__), "testdata")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _one_hot_logits(indices, num_chars=97, peak=10.0):
|
|
49
|
+
"""Build a [T, C] logit map whose per-timestep argmax is `indices`."""
|
|
50
|
+
num_chars = max(num_chars, max(indices) + 1)
|
|
51
|
+
seq = np.full((len(indices), num_chars), -peak, dtype=np.float32)
|
|
52
|
+
for t, idx in enumerate(indices):
|
|
53
|
+
seq[t, idx] = peak
|
|
54
|
+
return seq[np.newaxis] # [1, T, C]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _decode(indices, config=None):
|
|
58
|
+
cfg = {"charset": VGG_G2_CHARSET}
|
|
59
|
+
if config:
|
|
60
|
+
cfg.update(config)
|
|
61
|
+
return postprocess_ctc({"o": _one_hot_logits(indices)}, cfg, 110, 70, 1.0, (0, 0))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _indices(text):
|
|
65
|
+
"""Map a plate string to its VGG_G2_CHARSET argmax index sequence.
|
|
66
|
+
|
|
67
|
+
A CTC blank (index 0) is interleaved between consecutive identical glyphs so
|
|
68
|
+
a true repeat (the BB in "DNBB722", the 22 in "…722") survives the
|
|
69
|
+
duplicate-collapse instead of merging into one character.
|
|
70
|
+
"""
|
|
71
|
+
out = []
|
|
72
|
+
prev = None
|
|
73
|
+
for c in text:
|
|
74
|
+
idx = VGG_G2_CHARSET.index(c)
|
|
75
|
+
if idx == prev:
|
|
76
|
+
out.append(0) # blank separates a genuine repeat
|
|
77
|
+
out.append(idx)
|
|
78
|
+
prev = idx
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _read(text, config=None):
|
|
83
|
+
"""Decode the one-hot logits for `text` with the DE grammar active.
|
|
84
|
+
|
|
85
|
+
(`_decode` leaves `plateRegion` absent → 'off'; `_read` opts the DE grammar
|
|
86
|
+
in so the rescoring tests exercise the product default.)
|
|
87
|
+
"""
|
|
88
|
+
cfg = {"plateRegion": "DE"}
|
|
89
|
+
if config:
|
|
90
|
+
cfg.update(config)
|
|
91
|
+
return _decode(_indices(text), cfg)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _load_logits(name):
|
|
95
|
+
raw = gzip.decompress(base64.b64decode(open(os.path.join(TESTDATA, name)).read()))
|
|
96
|
+
return np.load(io.BytesIO(raw)).astype(np.float32) # [T, C] float16→float32
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
# Charset structure (the off-by-one calibration)
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
def test_charset_structure():
|
|
103
|
+
assert len(VGG_G2_CHARSET) == 97 # 1 blank + 10 digits + 33 symbols + 1 + 52
|
|
104
|
+
assert VGG_G2_CHARSET[0] == "" # CTC blank
|
|
105
|
+
assert "".join(VGG_G2_CHARSET[1:11]) == "0123456789"
|
|
106
|
+
assert VGG_G2_CHARSET[44] == " " # the extra symbol slot that realigns letters
|
|
107
|
+
# Letters start at 45 — this is the calibration that fixes "DWRO"→"EXSP".
|
|
108
|
+
assert VGG_G2_CHARSET[45] == "A"
|
|
109
|
+
assert VGG_G2_CHARSET[48] == "D"
|
|
110
|
+
assert VGG_G2_CHARSET[70] == "Z"
|
|
111
|
+
assert VGG_G2_CHARSET[71] == "a"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# Greedy CTC decode mechanics
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
def test_collapses_consecutive_duplicates():
|
|
118
|
+
# D D W R R O -> DWRO (D=48, W=67, R=62, O=59).
|
|
119
|
+
assert _decode([48, 48, 67, 62, 62, 59])["text"] == "DWRO"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_removes_blank_token():
|
|
123
|
+
# blanks (index 0) interleaved must be dropped AND allow a true repeat:
|
|
124
|
+
# A blank A B C → the blank breaks the collapse so both A's survive → "AABC".
|
|
125
|
+
assert _decode([45, 0, 45, 46, 47])["text"] == "AABC"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_dwro309_index_sequence_decodes_exactly():
|
|
129
|
+
# D W R O 3 0 9 → the calibration acceptance string, mechanically.
|
|
130
|
+
idx = [48, 45 + 22, 45 + 17, 45 + 14, 1 + 3, 1, 1 + 9]
|
|
131
|
+
assert _decode(idx)["text"] == "DWRO309"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_alphabet_maps_one_to_one():
|
|
135
|
+
idx = [45 + i for i in range(26)] # A..Z
|
|
136
|
+
assert _decode(idx)["text"] == "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_out_of_range_index_falls_back_to_chr():
|
|
140
|
+
# Defensive path: an index beyond the charset uses chr(idx) rather than crash.
|
|
141
|
+
# (The real model never emits this; guards against a charset/model mismatch.)
|
|
142
|
+
out = _decode([200, 201, 202])
|
|
143
|
+
assert out["kind"] == "text"
|
|
144
|
+
assert out["text"] == "".join(chr(i) for i in (200, 201, 202))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
# Captured REAL logits (ONNX vgg_english_g2 on crisp synthetic text)
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
def test_real_model_logits_read_dwro309():
|
|
151
|
+
logits = _load_logits("ctc_dwro309_logits.npy.gz.b64")
|
|
152
|
+
out = postprocess_ctc({"o": logits}, {"charset": VGG_G2_CHARSET}, 110, 70, 1.0, (0, 0))
|
|
153
|
+
# The model reads 0 as O on this font; letters D W R O and the digits are
|
|
154
|
+
# correct with NO off-by-one — that is the calibration guarantee.
|
|
155
|
+
assert out["text"] == "DWRO3O9"
|
|
156
|
+
assert out["confidence"] > 0.9
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
# Plausibility gate
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
def test_gate_suppresses_single_char_noise():
|
|
163
|
+
# A lone high-confidence "A" (the reported degenerate) is NOT a plate.
|
|
164
|
+
assert _decode([45])["text"] == ""
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_gate_suppresses_real_lowres_collapse():
|
|
168
|
+
# Real ONNX logits from a too-low-resolution plate crop → spurious short read.
|
|
169
|
+
logits = _load_logits("ctc_degenerate_lowres_logits.npy.gz.b64")
|
|
170
|
+
out = postprocess_ctc({"o": logits}, {"charset": VGG_G2_CHARSET}, 110, 70, 1.0, (0, 0))
|
|
171
|
+
assert out["text"] == ""
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_gate_allows_plausible_plate():
|
|
175
|
+
# Exactly the default minimum length passes.
|
|
176
|
+
assert len("ABC") == DEFAULT_MIN_TEXT_LENGTH
|
|
177
|
+
assert _decode([45, 46, 47])["text"] == "ABC"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_gate_length_is_configurable():
|
|
181
|
+
# Lowering the floor lets a 1-char read through (per-camera override).
|
|
182
|
+
assert _decode([45], {"minTextLength": 1})["text"] == "A"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_gate_confidence_floor():
|
|
186
|
+
# Weak (low-peak) logits: mean confidence below the floor → suppressed.
|
|
187
|
+
seq = _one_hot_logits([45, 46, 47], peak=0.05) # near-uniform → low softmax conf
|
|
188
|
+
out = postprocess_ctc(
|
|
189
|
+
{"o": seq}, {"charset": VGG_G2_CHARSET, "minTextConfidence": 0.5}, 110, 70, 1.0, (0, 0)
|
|
190
|
+
)
|
|
191
|
+
assert out["text"] == ""
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ---------------------------------------------------------------------------
|
|
195
|
+
# Format-aware rescoring — GERMANY ('DE') grammar
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
def test_de_grammar_accepts_conforming_reads():
|
|
198
|
+
# Well-formed German plates: district(1-3) + letters(1-2) + digits(1-4) [+E/H].
|
|
199
|
+
for plate in ("DNBB722", "DNSJ162", "BHKH2294"):
|
|
200
|
+
out = _read(plate)
|
|
201
|
+
assert out["text"] == plate, plate
|
|
202
|
+
assert out["formatValid"] is True, plate
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_de_grammar_accepts_ev_and_historic_suffix():
|
|
206
|
+
# Optional E (electric) / H (historic) suffix glyph.
|
|
207
|
+
assert _read("MAB1234E")["formatValid"] is True
|
|
208
|
+
assert _read("MAB1234H")["formatValid"] is True
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_de_rescores_digit_in_letter_block_via_confusable():
|
|
212
|
+
# OCR misread the letter B as digit 8 ("DN8B722"). The raw read fails the
|
|
213
|
+
# grammar (a digit sits in the trailing-letter block); confusable rescoring
|
|
214
|
+
# 8↔B yields "DNBB722", the unique grammar-conforming candidate.
|
|
215
|
+
out = _read("DN8B722")
|
|
216
|
+
assert out["text"] == "DNBB722"
|
|
217
|
+
assert out["formatValid"] is True
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_de_rescores_zero_for_o_in_letter_block():
|
|
221
|
+
# "B0DE123": the 0 in the second glyph is a misread O → "BODE123".
|
|
222
|
+
out = _read("B0DE123")
|
|
223
|
+
assert out["text"] == "BODE123"
|
|
224
|
+
assert out["formatValid"] is True
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def test_de_keeps_original_when_no_candidate_matches():
|
|
228
|
+
# A read with no confusable path to a valid plate is kept verbatim and
|
|
229
|
+
# annotated formatValid=false — validation never DROPS a plausible read.
|
|
230
|
+
out = _read("XYZW") # all letters, no digits → cannot become a valid plate
|
|
231
|
+
assert out["text"] == "XYZW"
|
|
232
|
+
assert out["formatValid"] is False
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_de_conforming_read_is_never_altered():
|
|
236
|
+
# A grammar-conforming read containing confusable glyphs (0, 2) must NOT be
|
|
237
|
+
# "corrected" — rescoring only runs on a NON-matching read.
|
|
238
|
+
out = _read("BB2034") # BB(letters) 2034(digits) — valid, has 0/2
|
|
239
|
+
assert out["text"] == "BB2034"
|
|
240
|
+
assert out["formatValid"] is True
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def test_rescoring_prefers_higher_probability_candidate():
|
|
244
|
+
# TWO grammar-valid repairs exist for "8B123" (digit-first → invalid):
|
|
245
|
+
# - "BB123" (one swap 8→B; letters BB + digits 123)
|
|
246
|
+
# - "BBI23" (two swaps 8→B AND 1→I; letters BBI + digits 23)
|
|
247
|
+
# With one-hot logits every swapped glyph carries ~0 softmax mass, so the
|
|
248
|
+
# FEWER-substitution candidate has the higher mean per-char probability and
|
|
249
|
+
# must win. Verifies the probability-weighted selection, not just "a match".
|
|
250
|
+
out = _read("8B123")
|
|
251
|
+
assert out["text"] == "BB123"
|
|
252
|
+
assert out["formatValid"] is True
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def test_probability_weighted_selection_uses_aligned_timestep_mass():
|
|
256
|
+
# Hand-built logits: read "8B12" is digit-first → invalid. The ONLY valid
|
|
257
|
+
# repair is "BB12"; the substituted 'B' at pos0 draws its score from the
|
|
258
|
+
# softmax mass at that aligned timestep. A confident runner-up on 'B' should
|
|
259
|
+
# yield a high formatValid repair regardless of the exact prob value.
|
|
260
|
+
charset = VGG_G2_CHARSET
|
|
261
|
+
b_idx = charset.index("B")
|
|
262
|
+
eight_idx = charset.index("8")
|
|
263
|
+
one_idx = charset.index("1")
|
|
264
|
+
two_idx = charset.index("2")
|
|
265
|
+
seq = np.full((4, 97), -10.0, dtype=np.float32)
|
|
266
|
+
seq[0, eight_idx] = 3.0
|
|
267
|
+
seq[0, b_idx] = 2.9 # strong runner-up → B is a confident repair
|
|
268
|
+
seq[1, b_idx] = 10.0
|
|
269
|
+
seq[2, one_idx] = 10.0
|
|
270
|
+
seq[3, two_idx] = 10.0
|
|
271
|
+
out = postprocess_ctc(
|
|
272
|
+
{"o": seq[np.newaxis]},
|
|
273
|
+
{"charset": charset, "plateRegion": "DE"},
|
|
274
|
+
110,
|
|
275
|
+
70,
|
|
276
|
+
1.0,
|
|
277
|
+
(0, 0),
|
|
278
|
+
)
|
|
279
|
+
assert out["text"] == "BB12"
|
|
280
|
+
assert out["formatValid"] is True
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def test_off_region_bypasses_rescoring():
|
|
284
|
+
# region 'off' → no grammar lookup, no formatValid, raw read returned as-is.
|
|
285
|
+
out = _read("DN8B722", {"plateRegion": "off"})
|
|
286
|
+
assert out["text"] == "DN8B722"
|
|
287
|
+
assert "formatValid" not in out
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def test_unknown_region_bypasses_rescoring():
|
|
291
|
+
# A region with no grammar entry leaves the read untouched (no annotation).
|
|
292
|
+
out = _read("DN8B722", {"plateRegion": "IT"})
|
|
293
|
+
assert out["text"] == "DN8B722"
|
|
294
|
+
assert "formatValid" not in out
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def test_gate_suppression_skips_rescoring():
|
|
298
|
+
# A gate-suppressed (empty) read carries no formatValid annotation.
|
|
299
|
+
out = _decode([45]) # single 'A' → suppressed by length gate
|
|
300
|
+
assert out["text"] == ""
|
|
301
|
+
assert "formatValid" not in out
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def test_substitution_position_cap_is_bounded():
|
|
305
|
+
# The candidate search is bounded even when many positions are confusable.
|
|
306
|
+
# A long all-confusable read must not explode; it returns (kept or repaired)
|
|
307
|
+
# within the MAX_SUBST_POSITIONS bound without error.
|
|
308
|
+
assert MAX_SUBST_POSITIONS == 8
|
|
309
|
+
out = _read("0000000000000") # 13 confusable digits, all substitutable
|
|
310
|
+
# No grammar-valid repair exists (would need a letter block) → kept verbatim.
|
|
311
|
+
assert out["text"] == "0000000000000"
|
|
312
|
+
assert out["formatValid"] is False
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
H4sIAIOPYGoC/5yXhXcUyff2cXcPGkgIUWLj093TE0ODw+IWHBZ3sri7u3sghMhYe1V3VXcPzuIsLO7usNivv++f8OacnCSTpKeeus+9z+du6NA9s1Ov4sWmFpvVYsjQSVkTWzhCWxDDklrEhrYYNm7i5ImDxg4YN3HI0P+9njFozKShxuuTRgwaP9T4OdJujg21W6NiQ/8K/f/8qHDQXQV8d14NsEIifQBNB6fls9IoRCkH0Fe8Cs3DLtAblnfsApwUIXXC45XdqAqOB1dQKr8aL4G78Wm5NWiDbWhOwXXZwnQQK4FDkEqszu9VfqQ+A58TCOTjlohHxUZCP78ij3EprnmmHLqrsF2AohxvKXwjtiJShZVoqLma4OW5pDa4G1yE+ypvcLYigMfsXHSPeC7shGXYUrgyaoPuyxvgDLkjrIy6YxtXS7mh3G79BI2FpZS76C33Vl2CF6gLkUmzaQ30Z/pZNU4DSjFcHrRCNxUNX9H3q6o2Wo/CJzQME/VI3DB4GP+pmvS+ejtlLn4LTqE/1cPqG7CDu4uvOxLVAfJ4rSJKxVNxY/wNdsSpwl3vN+hL/AONRl+RDhtABx4v+NAq7ZJogbuUM+APPVFtom9Rh+kjtCN4qHxBnSsAuT8eD1dqxbQ12hQ8DkvqK9Rfa6xjGeLVWpn2zzVVraPF69dxiWCeXis4UAsJuoONTi07dUZ3BZvr37Qp6jXts3pHb3fqsF47OPOURa8SrKPlBGvp408N0zOC74PDTtXUN2j78ES9ZzAs+BEfFJrrl1GJ4BGtfbClvkmPCVqCWLXo/2InMukNlQQdabv0M2p19ajeAXfSOwU/4Ts4Vp+vdgyu1d/qQP8dTAxC/R7O1UeiI9iuD8UF+gR9ULCroeJfPUQT9VnBKLWLPia4pd1LbZLx6mttl/Gbv/QLerj2VG8eDAbTTv3WqeAxvar+r1pGz9Ge6LuDM/WDesgpWj+pf9HGBkvpT4J19C7BJ8Fip9brcdq/hoI/gv/oi1WFL6aPVs/qZ7X3emu9ULcHDwcpLU6fqsrorv4IJemvtDP6EM2j6vpDvEkvE2ynbTbUFKnz9Zp6K7268eRfeoG+CQ/UQ5AJu/RruK5+UasTnK7atIV6LzVTbxIcrpbWuwb3tgHqAXWNmqmatDK6SRurVVEj9Un6Hb1tcLI+RU/Sn6lp+IK6W62pu/ThWor2Qie0fE1XV+hf1Se6Sd2ohwc/6M30umpZTGlr9H7aWTyOuaqew9e1CmpFPVb7qY3RX+if8UAtFGvKIH0y8qv5anF9s2pRS+g2HKlH6tW1NTjB8OUptZi6WF2O/zOUm7W6qJV2EwClkXYDlVXf42r6FRSlCuo99ES16StwcXWSfrbVYxzER/G/6IgqqeXVNzgdZ2uPtEr6IP22dlzro41Ud6N3OAu/U0erq9U7uJwep/ZWG6hFml2N1l+gm9pivbw+VuuKVdRArazvxWassTFqOB6iLkcb1Hv4qrpUa6sXx0/VMATkXlqBMhAH8C1VwSzere5C87Vf6kb1p/G1FOqJZ6OSeI+SrDfVcvFWuZvaRSwnn8GTldlohKE8ROmCQ/Bx5RD+pGqIQ4O1xHQKP0ENcWN0F3dX++GK+Db6poZqcdoWLVWL0u6rXw3v/oXHogS1G/6JWxn1GI7X4um4nHYVj9DSUEct3KjTTnUkXo224ixtODqC+gnP8CQ0Bk+UT+K/UT5uo/q1xqinOkSpJfdRN8hjjFN+xDHYhfNxB5SnVlGj1MXotkrKu1G+bEdR0KG1Uq+g6lDH/wi9oQlHyK2UBsojfAVCFIGeyN9RHUN7CBqtlkjbj3+gJ6g6IrFbdeDKuBaur11Vn6kWrYtWX7uh/oefGHPGjn7hUMzgV6idWgIn4nL4h1HVxlpLo84/jF5PU9fiXWgWjtBMaDWaI9rVG0p53Blm4QboLRqH+2pblCd4oNJUrqVeh2HoEJptvNNJNABno2HqFXwPd0Op6kUwBe2Hk5Rn0nb1iKF9LGiPu0i7YHUcKZ+VC+RJeB38A5VHXZU4/AzvQ1XRLRyVNk2dhz+hfkZFJqjJ+IjhgaUaozbULNo0ba8WqcWpnZVzONJQ8CfugRVDwR3kwjsQpa1Vp2vj0WHti+GnJHUjfoQ+4tfqeLQbeXmkzjEmdDE4Fe9WRFQKL9TeKFXUxwpp+GIyjEDr0EuUiV1okXEvK9Tv+DKmUDnVAbqiGOhUbor91AycihNBFbwb0LIV75LvyTfkX+gDbIr+QBPQDfwd6yjFUHDN/a/6J2ZQGUNDlLoMvcJ1USOtkzpS7auW1zpqSepz/El+g48px/FynIT9qL56AAXQfHRaPaou1HTUReumbcaH8VUURNeN0yShDKSAHPWtHItmgGjcRd6C9qMI7YwSr65R1sN56ip5gdIazUB3kVM5jF8YTpiKO+ErymJsEp8q3cEBuTF/E79BX1C29C+6BrywCW6g1JRTZB86D1/JfVAPtAM3xL1QpDIIl3UVqANwC4Tl08o8vNrIrVLKn+oNvAzXU/eqn9XT+BKuLmN8W26NKbzf6KaAoWI2qoR2qHb1uppjTAWvWteo+H1lDlqDv+IFSmP0h+zQOsphSitwCFlkm+FRQeWVCDVbPgzf4zGyLo9UDihj0GU4EPtQZTWILqD+SnX8lrknt5RmwuZMY6Mm41CeCA0ntYNtUKT8EgigGPoKFsJGKAm1w6PRXmWenI/GJhxWPbg6+iZnoDb4u1xSXS33VQEOokbqEWP2DMY/cJL8L14tV8Ib8EzjnZYYPXACBZR9alW1UI3A79XtKkZFKF3ZYfRBLbWuYkE5yhYtRi6tVIEr0Sd5FyqLLqoUmqs2lJ/CG/i+fEKOQIIyFW2HPOZQU3UTOo3qo9oY+7rKl8V6cERAQ4zSBbWTLqP64CGIQUOhZCioh0zwEpipVEStcAa6rXyWh6J4V3l9sHYG2/FU4zw18Q5tFdI0h/Yab9Bq6VBrZ+TERTRNu4MmqnM1EU9TLxrZ0UYN4P80t1ZCf6qG6DX0XHW+ehv1Vf9Wr2vd8T68GO3UD6EOOAGdxM0MUqltUMIdHKq/RZnogZZgOOcm/oBvGRNuuVZeXaNtVNPV85hVt4pr0Cc4T/4mpqmN8Tn8A45RGysx8h38SOmi7Fd24RjlnpxraO1vdP9ntBZpuGbKPH203lx7p4raQ22yatZf4D/1jnplbaO+Wx+nZxlp/QqTuludoKXpfjVb267N0JZoV4wkC9N762X15bqkV9OvazWN00Ntj75OLa7tR2TwBg7RthgMs1PdoZ3WqgajtH/0FHUpfqj3U1uoj9UlBufcxQ69ozbPSK+dWgWtlB4GC3AUikRr4RAtTJ2misoizYZijUT7iI6ig+iSGoG6obq4ujrImJQ3cXesqMWpsQa3DNWOaZX1UIMfTHo9tbNuMZhgrp6v99GztSfa3wZrmbWquknvqmnaeiPnXmtnjfRtrTc1cnqkflV/Z2RCZ3WQwQoL9KBqTAIcGiRVpIWqXYxEeaCF6BHBOI0KVtF+4orBsdomta/BGF20x2o3fYdWpK/Wm+hXtQm6ZHRJH1SIgnCJdhtb1IrolTYJhRg6aHwIvUHdtAboIvoX1VZ/qVONp6/DJTWN2qQH9Yp6Yz1Gn6ZP0bL0OWoPvYtxDp+eENxm3G4VfZaWoZfWu+vh+ifthnFTxfR4faj+QR+hE/pco4I1g2mG1g3qWr2CvtSgiq3an7hbcKdxG0gtqw/UEnSnPix4VBsWvGpM4A7BJD1UK63P0u9reVq2Xk7/oT/V/1f3a3qInKU+RW3xaPm3dhzvVQ+jhgZ1LFEs+lf8j5FYhYbHU7GGBhhp9b8UOoRXaS2dhJ6tX9CuaVe0MH2j2kKfpNYy7rSY2l9/p2cY1c02qv1G+0v7pc3TWmov1MVGRuzU/tPy9Ajj7xKMz0P6WqOCUWqKfkArZ9DSU/U0qhGsqXq0MWpPbavaUQto/+mVtDeGJ9/ga7pPm6H20KKMGpw3uMij+QwOa2zc4lh9BnyHj6KraAhM1K6ijupINEprhRspVfSDuAs6iapopdFIw0fJ6lk1XWustsCVtWm2tsaNewyqm6Hd0aaq1fXl6lXtssHhy/UHxhljtGpaCW2BdsbYGMZqtbSXaoxBda20pdpS/bu2T8vU72mzDIZ5avgjXme0ucZvt6om3ChYS83V8tXW2l61unZdO6eHaaWCe7SLWNN5bZX6xiDjdsbXEvpJDehH9CfaJ62LfgF+xrsNl8yDQTUXRagKmqfl461KI30/HoXWoIZac1QRbUV9VMbw6kl8C71VS9rHGf1aSm9vsG6y/lutokdp6YZfLuDywWbBwQbHO7TDWpZx/oX6ZqMWpNFBuhqvBbXzuqJd0GbrZp03tD3XfqutDcVlDLYO0YrwjOAqNVwvp53TumoztVi9VPCC0Qc19HnqfcNrcVovbYfxvJLGdKhl7AP/6g0MCu6va/Jo9YfBU8fkOsZMIwwSe6+Fq80RpV/F39EvBLVFaBoKwWfVFto4484a4gfqBKKPQaFWfZRxI330BpqstdbmGp38HjcLNgquNKjToTXUPxi1OW+4t7t+Tk3SeqinDDJvGlyovdWmGg6+a/T5K2MOjTTuIUK/gSdqa/H24Fs1Q69raH5vkG5Ho6deas6gW7+jnjc6boR2Vbuob9RGaXbDqe8NlqeNPhimv1H8ajO8B9dAqdoT3EUdg6vqvdU6qKNeW+2Edxh0vR29QN9RvNZZ26ENMsijopZLVjT+22ZMFUJrrrc1ptxdtZ3+XpuEywY5vZexo/RVz2h11GnGLJykkvpb/FDtgV/g01rpYEVtvNZeTzb2nWx1snZTM+kl9UvaBZRrdGGPYIhRl8MGg3/VcjVCLxGcafRCmH5XnamXMGb1Bu2EnqJR2jKN1rfqO/UWRid00/uj/eoptBBb0CGDlR7hOMxoqWqh0li/jGPxWXxeW4kwikOJWk1thTZWDcFVtbaOsepvw9crVbNaTzuGduMC3NNIB0bZrt/UehhTeT2aqvZEPtxd34wztTYoTS1p0HgpY9LcwGFG3hXTQvVnaDluq9000nmuWiC/VDvj9XoXY4fYb2wsf6qsWlXrqScYM26bGqI+Udupd/EetaNeQ41XJRypZWptDXeW0w5pj+R83BCNQgugE4soD1GIUztiUt6idkXt0HhcXNOVQYoJ7jUSYab6DqvKRJVJW6N+ULepBYbr4g1O24+9uLSxV2WhU/o4w8OLcCt01dgeXqN6+hY8DR+Ux6qn5LXoMFqrFeBCvNU4cxlj63qEyqs8PmLsV82UNHWockW7hsOMXaOk2gdfUZsZfRqnflP/NXb+m2pPnIvN6n7NZnSqw8itq+pb3MyYs1/UMwChIUpn2SfWw6SSipYaxPwCqVDDw5Qrylk0X72E4kAOLIsz1GO4rnFb/+IJrTfhKIPt/kAH8EqD5V14Hj6nNlW3KV2MvfuoGoYnKbfRZ2BBn433KyYvAs3VreC83Em5onbA7w3yL41naR55KbqqlERncUf8G9bDf7MGIaCtiqAMQCakq23VLarV2I7Wog4Gv45TAHqOs9SduKvc28jV88a9/2Nw5Ez1k/BebglPSTe4YeiX9EoeqHyAs+QGsDfqDSPkUUozfANJgVmgKdplEMkgdZ3SHL9tM0uphJ1yEeiOFPgUxMrVDcYajypzddUPCMqcUkUaJH8OxeJydFGua/vBpyr5gUfiC2GaMgNWlfLgdWjBTwRBDkhZcIjSRCklnJDj6UY4DfTx3wNx4jBhEdqC3IoG0+R3Yhf4HnaUHkmz5IsoE+bxJRUVmGBxOAXx6JzSiKktiLzpRBVLDViM+YsvDUdILmEIOCElche49mKEUkzeZx0n3RUEOEN24e2wvyy3Gwj/kfPZgbYQ0IkfGbqC3QIWi3Z5PTlCHqdsN13lotmSBe+IVJsVJMGpKf2OF4ofiM7m0TYbE2TaU3ncu/i6cgJ1RFpiOeafUHiNaeo6AN6nF4DbyTvpXYFs0z/EZOk5eC/dF94LRxPDxRxWy7mTtIlhwcyWZlNDqZ7nFFfP80uAMitMJqo7dhFdqTR3zYIVxCjiEvvBttqyDJxKaOsIkE3I9qzI76T6BVo5O7Id2B/SGqGs0LT9OV95viJpcVOFLRJDyRyb5KuWUFrIoU9yJ0A6RZE9Az6yGT2UjPPWFRR3gr01U4YOJRz0buIdscv9xv6CusKZ3FMKrZSf7O+qRa5J6WnLSTf7q1A7UnY6Z7vausLY+kwSO/TogkApaoujWFxD4jS50bRyf2fXSeqqZy0RSZayxDufC1/MHelz5AHXLNc5WnA6yaOuLrZ25DOymnjaPsClu965plg7m8KpDn4XmekYVzTQpjXL8ie3t9pWx74kc2mntTXf7cRZp4fv7dC5lq6beW/ZCLIUHS6cICxUCXq57SD/xv3S2iq/vJOL/U4/cj1xVUpJJz+SpwIDqMaOr/RXiiU624q7K1FRaSmJJ8l97v7EY9d5yukdZPXl3iT448sojfxu+mCfQ9Ug/fZB5HzqfsFMIoq67bjuaBvgHO+pMKfkzHOMJK/ZRjhyXfUdrcksYgZbw/mc2EUtdq1xlAw32+/zc51zbFJhGdtbewWuQ3sTMd9cklxMTXdUk1oHDhFXuDG2DH4xlZ28syjT8RfdTGzmuGkfSe8jyomT3JuTYy2TbYI3lR7o2uGanaJQf5HnGJYgyV7uD9RGSyMz5bpALk49nNCewO5WRFM6g5rjiXGu8Hx0Zh4MIee7tpkG234QUa6DNo+dpkDRHeIW8cQRYRvuHeGcRix2lrA0cKQ7yljWO8pS480BQnFs9fdy9HEWJ+65atlzmAxLhjDH8Z9llWfgiQrOKvyM9u+di6xNyHlUsvOEuIRNJruzb2zNhQzX0oTP+bOcbekdQq+k+sc5+hsRBfq6x8V0iB9mrcSNce2jRKpbyiGqC9mTDTomUfvdRXTiweexV4g5xMkUX9N5jk3ud84s+iG5oiDNfsz/2jG6oDcxip598pDtmmOp65hVtL4h1xa2ogCx0FHCwnpXO1TbdYfk627/3bK66Z51H+ErqElssE84AcxVnM5EzvXTWkW4GV9aqEPUM8vM4sA6+whhV/vTNosplXhCPbNfFp2C7qzASvbLQrarYs4qzxhCpDA/1j+D/ZdWnPNhG3fL/MzAUVuKoJJzqfvEHvdMcgORwb81Ieo1vdhtLwgN1LCPcFx0RxXoNpkOd0ygXxCrCunEGuxAR3HPZHs9+gkz09LbFu1abXqWYCdvFwWpu8Ry2+iEr/52tq2mf6zr+HW2NoH7Lfvmu2zFmHBHJ9OSwqSCbo6jvsNUNRMtVTkRKrQn/ouPES5zi8yHRKb9gMRNJxz2w9Q0W1OwUzpqY9l8e5R0m2rtWR/IcQpEaX4Q31gM0s9s5+WfdJb/C9/Q9o/4wDmZ+t8Z052qI1ls4btK1XA1STno4fj70WtsZd2Kr71ljOu8JYne4cj2DvF85/+2rfO2TFxPzRO+RkSY9lCNDvcvmk5U8qVR0cSdWKIAM60TpfB5MTHSv+Yu/OMTo9hJhVF8WVsZ76tAa+awvSXbg5yWnwPWFO0TnjlbNJ4mNROu7fdK19rbClt4bpr2U6VtvwAGwBLGtnfsAVOo2oEsbrj9uGMj00UMAZ/oBSZG2UDfY4PiTlORlGHfQeXGRtFJthxbVTCMW+JyEfdT2viaSqz3t3U5PZ35mnyG3FSfc422PPHeCoRJY80rfY2KrhEO6aBvaNPjlOzt6xtM/PKVoKY6pvpCvN+5AQWDPOsKr4CKCXYxtGCM0IHdJJBmJ5vHXeIe2138LSIr0Be6vOXFhkT2xoUgRljq6QS+tU/0jspfmTjGdduWDRNhhs3rjyc2w/nUKGYnf806wP7IU0paD5a7zyZURtl0P/6w1C/hkDTF1tL1u+CTa5+llG0vKCNMpx/anqQ+9U4C7QJfbCvp4SyTXItc5p3sWhPbt/A42wZcSnjp4b2E0y/VZPflT3W99Ud4HxEtvB2oEfZ9TLT3C//ZW+Tv7gnCjGOCNCQ8VJrBJ4hPzXe4PnwTvqqzoTCQ7MH6YS3vZeESEZ3gBfX4j765oEFmWsGTeJTUk453zIT1YJKjReESspEsUvOZq/wTSy/7zJCZ4jZgSbmUXAE9otvwX6W0BEoaby9Ffy6sYMzYHjYOvBIi3ax1fNpkz3PwNrDGcZk+xXw0NSfpwGQXkT87tpBbDlbGNT9R1SvbJ0jjOXuhne7ha15AkdaTe6m29lWs3zNTQP5A4HhRhry2oBNYaA4DZ/k7QtCGuen8V7YLMUJIofazp+E9TwdhOekxjwcbmVT/n8CaqcY9M4um8u5OTjc8DUoRqc1PUz3l19SdgMTPsnjtkxLHCyOBkrLK8lSZ717HlQHLks6LSc7Z9OH8eHqU46NtN1gmHnX/YbuS9qygFpzGlCfquXsGalkmkyWZ5a6HRYPNzfgd4GHctcTZRX5bOekK92dhDj2taMORwSQfd5baaL/IDvCcFLYHajLhhXPkc0XzwGL7OMDzViHMWZxby9dj4snOQnOXgz0Lb3tu8/WMHqwA5vla+dJBZmZN81LbOfNRd2WiDJwNLhGuxKBrtlzKZQvs4ItZijsmJ2fxCSAqdZWNV2qn7GbLA8KULc4lHtNrj/9BVyCO2qeBV2KplO728HQtrwEsyRYRqe4j3tsWjlzF7HLFevvYuvG54G7LTZZ+BVetkviUm1tY3D0s707kNHJD8nXqgr0YZ/ecFTozFuZ+/gF5hGcjuOFcCXbxu/lCIobdy2PfV7KO8N51hnkAgWcdv4ya5ugp1Sk45GkOBmRqVt1e3xKe4ibugAywjFSTCfqQ/JmS/fP4fuY6jnHJpbmyYGTqbvtuZUnKF6Yu2G7qLaaQEe6JDWfRh4h0R29ASGkpPRxZ6TinITzK0uQfbmthsvUlWZ496BrrG2qfz2tAaJlna56/3HJILMGfLkxxv9p3PWoemWiWqat2F9fd81qoyPZhwvNVWfUcAJjYCNbyCfxucimzg+/nWUnd5NvRm5lqcp5nAH+VOuXkxdlRxQrrgHGZZ2xXHX+Zs1MyifMgAkRTTUyT6QuyRGn+qfx80zP7hKQ9zHNpceokxwbl75RchgAjTcPEiyTtHhu1k65I5jtagZXSmBSLc2v69npN4CzuCNnTXT23mbUMtZjd7SoZuG4/zr8B0+MC9pL5/c1FYiPeUjTSPTXybeP5ZHHLRspnn8ft9dQTJXYv0yj/ndzZC0AncivYw//LtaGSmMV8cuE9Ko+fRx9h2sk3PCT/ixpB2MX1SSEna4N5mf3tt5xvkv0prYhzoCFQqNFJu+jvcncqIjCB15MmGX0wKfBOWpoa7tysfE3hmbmgT/JeMYKi3M1iFbo7edrhNMhzTkpp4lT6yqgw2IN/QLrdd2POWMpTpbn1rjrM33aJD4O5MXscv/KdpgdiK35O0XT3vKhuuX+RActg6rj9MPfc01rsyzVmaxVEK5m+H2AjuR9c4Qu5o5SZGcWHF76m9vLb6OrsanmjtzFfynWDQMJ489PcxmBz5k77VOJG3O0UkrgJYsBAV69YSDdXfOS2wDh+e/wR25eER76aYGwq6zyifEqJZF8DmFBKMlNR7oOxX+h15CJHEqDAvJSJxM/0sXGxMFH4QJZ390pKsXwhp3ELXNnsWHshPxj6Ixc66xXFJZmkRfyMonHuteGrCqaTR6xJ1C37Hq62d4Z4g3vFdimapnz3D4SbSQ5YhWucn9rHZPKTPMcojl9FP2dvyi39v7gHVFuyt+Cwxuclg5xMu/0KkZX/PqUU8QF0B1dc8/P+prsqxcn2zBR+VMNblgoJ/3jTAZF6x4kVLeU3uwQ+jhkrfSTLuY9EJ7k3ko0ccWAFmJRSRMRmLE1oDWuKN8lDdA1zV/NtMsCNcn3kTtqO83kwsWkDIs17PX6ndJaPLursXnXglmc8SdsekN/s+7h0b46YwFfkXd6LSiRzBg4jXwO/UJVfT01jo/nHvibUJ34gXY5vqRwOvOJWULvIn3xrW5+CQaAwM8r2mNjq+5jyzFELrgVfXTaPTC9R+hCp7GL+r7w1yY2jaxu5UTy1n/ODkp0SypvleY1YaR55ny65bqibJq/Y48Bl8EfKYaJtxpOkWdAv5pCRdCXLu2RA/s1Rru18J+tl/hesfTCdmOd/HF0cVBbOFka6Nx2t7c8ip9gGkzvsX7gT3ivicj6PL+avjf5km8pNSTukxFA+i5rPleGZwFTSJITSu/glyjamJl+WKiIDfMCW5DkMPJlXzDoxlHmUMsqeDM+Be65dvgM0o8x3phtz+K+CqtH7cu/6/EBL2eyoh2JSrvP/yusOv5XqkjzdLXex+xSx3p4I6sHElClEj4ztJj9sIU0lp7hSLAmJG8lP3E+qqWA1NxfS5eFHehH/BCoejAJ24VDhL7r58XWBbuRq2y3iga0jn+j7Ib7mE4WxAQrt5gbLN4hlUBDr8QRF8584L3OTGClAVwvhhtKKpfil5B5yC//VttX7wlDQPHkzkcSdTnlk7QIrwJOurMAi+qXSz1GT38rXLEo7iQtF/yOwIeWK3Y7uuxcLExXb8eoAElvp7fmr3d2IJDsBesOyKS6ie8Zk8xu4RGpFPqEqWMbHdSHr84ep7cKbpKXCPDkpZwgRZHwnWoM+wn+FN+j9JyYwqeQ22zCirE3jBV8TqYuwXBjJZKEB/HZ5K3EJmqTSfAjl4UP4UWwvYqvQ3nVEqIIkdhgfRk4j9/Ml7Cd80dCb+SWuBzGYz0khzNNhBpzo6sOMoKujTPt4/ig/3PO66Li3Y6A6HJrS1N4X7XI3E98pTF4CmEFMpUcXZbsl515bZ7AL3nJXIDIz7pvD5ddSCLmbWmM+1qQY2YG3UhXF6glvBI8cnTucWMWOLxgGFgg/izz0+HwHG0dusL11WiyDhMH+NIkXTgkj2cXoBX9a7kXUkY9Ij7jPZDOhD/+J/cepC/9QkWIUqsut4v8mMshr/E3bQv8IQ0HgaBmisTA3ZWniEbgQxrlasa3pBNTTJvMav8F7zXvffzpAQUfKSNts1Nt9QoxH3oJewEJ0pzt7u7pPO/ZYJ4JHsNB9zJmScdHcVzaDF8QM6qEp5sQRYitfRM4SYVRX8aF8Om8A4eCaexYBVvjXs5IOFBbjqht9UM3pMoWI9QJ/SvFiGXE4dxzNEL7JtYgechVwnjtFbhQK+DFcA+cvYRCVL7rRSC7ALycqkAnCYluLwDFDwaSCrs7LQqeUGREPoAofUhYujO6MfltqCV/4Zv7l/m+BfsxYWD3Fad2H6rojpQGoj2cleOVMpiv5Q9y6fa55N0iSV7hppznDZd4uzwUc0YeqZbpY0Jb4xjcmX4k/j/rEGkpkQQfiNnfUexiUEKv5BtIXPH9zzwjSFurom/BE3BTYK+WIrcQk/m9USWyuLHNulluDc9xGsqpYSbjHDXBQ4nUyReqNVO45n0pA4qBQ1rY98B76Mi95NjkWig1SMk5GypXlbVQd/r1rElpuHiOYhPmB5YF7TGl2N3zmzjZDdJ1eLS1H37wITHJWpQcFbtCCrUHS32CePNJ93RGZ4TZdly+DlUQy1TVpp+eWc7iwhOgv1c6vJVFKRJGFoPhwvwa6i1P8CfQcX2/eQ1yztrDfb8xK4cxtqam0WjzBf0d7xE7Kdcc1eQG4x/UkN4mDhXj+kH2xmEpelyajBnxz4Z1zLBEpYks4Q8j+TJ/vmT1CeuheVjRe7iJ3ptbyO1wbUA/TGWGKsJBJYvawDHsKQveg5FtoH31Dykdj/d9AWecFV1NmJ/2X9WJkWRiUO7unOUIzaifVUKLhIOInOSQh3pfovC58dwalH4UjpWHKSk89YhVvDTwGR8XRgRJ0s8AZfipR2Wq2xZ8kgY+pZrDJFbGR0BS3kKYrkY5aCgtK8g3IUOmoMJF/ZzsvFhBJYC3qxQ8X+jpDiMNiWzNk5slsZt3ASHueVOBu6y2Q18k1qeJCpusEOpWULBYKU1gPE8UN5l7D7e6KCe/QDLoEuICIQBgsdMxxnWT+pKH53gkrLKWkuZs5GmWMjaeVGZAkAuS32NH+gw5SjHLWAI89+dJiZaD3lfMGfzJQDL4WizGFrs8Bq2AmYi2i5VjhEkCwyeC2VEOaJbjxNmm7ssreRnkOGvAPiKPSVcHPd7VVlhKIP0Eu2sznCBccW50VpVXJWSwrC5nLAmH28mCJu63vvqzI98h+Qk0XQEMSd4oPhZZcOXYO95IrL091D4v6hbLoCuAZmh4g4UxHS9cPphOdZTpZ0Ae2VNzu2/YmGYdi+iksDCdGk/aIegHasUrMc1hAnO+JtFv56s1xhgqvAjVhI2koM961h1kvfHa2M483/+EpAkfZLqAMcElQ6IlfSD4lxD5BqQ7NvJ+oBl4KX/l8q1Pa7dwMVKTzD4WejnBnK+lUwhf2vgwz8wL17BFghDvo+y4/kAG5S/hA6Sgr4ZdYVtzAxbM5XBhfX+7u7rC/NO5Plwf/oeGBdNjL8YAqY0yt20kFheNga6MGufYWGTGRI5THsB4RSm466AhUdEjiCkcHkOYvC/KU1r5hzgHC20ATmCCtZ1JdFdgrwn7nHNNGk+QRwFt2JKgMekqvhSxcBejKVdtSpSVsz28hXOC50FKobe0qxThzwD/oMV9F/G6/7+gq0XFjuE8yylwXoO3xoIf7ie+b/Eo+QXLCa0pFPeI7SyHiJi6O9XImPky2u8mjZfFQugwojesYNWjtOEJdM2rgTyxWOAl2U1q719jjMyKihig/YV3iPnE+p0WguOOSmOvoC2oGagKf0s0X6dwoIMOBLaQVTLSrJftdmO5kkluYtnlyQDVuOigL+kmlxcG4LrioKLbVCg178wuIvuCa0Fm4YekulXUeAU/Rcz5MBPZ/HMlS+ygv91nWMjMDS+yRoJP7rO+O/FDOMRSUcAmIaLlKqifauYqsj4vjm8tN3T8PlcUT6LKgPCb8UdDlmEMtZIbQ0Ykl8kfBHoaCRXZLBhnXXSkmNyYEotfxJ/66jnPiB8cgMDzQALBKlO+jQxDmBJrCctJApqmrGltGnON8mVTHVNGzAFTgssEHqatUReyJ64ALymXbYqUt7MNPJ4YBUegnvLekSS2cG8FrdIlvLB62V3R+EqMiT3HPZD2zuLGBhBg1OOArks/I+eRBoYUrHxFxuVIFcTt7jQFcLaMGVd1S47J4Bl3DUODwVYFOx3gqhplJf034M6cH7K7QhgJrxrkEt/IeRhF+wnF8ht/u4ES7cxDYHwgFgnLHe9bxj2APNIC3xHCmiUtm/hMKnDlJdtPOwp7gCTsNXJMoqabYxnBRUPlmm6J0gO34KcQAcEToILSyRkjjnVPBS3SILyHm2gc7T4jvoo9x1wwFO/y1HRVBlnutb758UhbJJUJX1yFUJvak9FF4wOxjnnHPuWZyCXebqPJ4Kh0NyuHx3s/A7phOvQksp/WEOZFuQ0GcoSA5Y0RyonIbJhAXieRjGf7OjqNiH0PBmUBzoCg7vB7HO6GK0cke8X2gnmswc1f46XyQ1MfUvcABGHYMOC1FS41EBy4LRKWRfZDSEcbyk4l2YJcQLRRZS0uisyd4hEby1wXNrjnHiaktB3BBw0VH/Y0c5cBEt+jrJW+WT5MThMmufWhY5C7podCeWcSE8QwXJf9Hf4mpgQfQHY0axHufgzjHWupTYBkdnlAyLhEOVKq4pxidfN+UqJyDcUQZclDORn+GocDsHA4+B+INX6z37nOUEhsEqsG94rPAb6oWowvhRP9kwvQ43w62swPA/zLNJCbhMkbXtLB3VrrA0vw4wgRyhO98CdsL8ZEzDdxCcfxx4bG9ItFF9CeU4HSjk5MD5w0XZbuH+tvJ6+Uz5GRhuusoqrRvofRcKG/UYDzv4Sj5PZ0dHYYT6PmgKj7l/QTKOY5SfZjRdLm4vjFJcK7ygG5vr5Wx3NxWuQLrGNxV8Vg3o5PPiAccM4GFSQXPlJ/eJY5GYnagHvSK0UyQKsWoAkGAZGvyx4LBIIftDaqABGmgaMO1QUD5beus9IUi14doDi4J5/lStsci72wFLqNS/DLho702MVq8mciw/8ogszRT154Oprn7B4bKx2UvuV3o5JLQ7tzJUg0xg6nO3uK/cMPkp/TavTQ+74KgCZ7tbwqL7IVUIhtDwwPLjmVCVsmjK9i/prc2L1J+w7PObPJAbkdmgr2JtMK+D+xjhoDyqLi/p6OV8cQkWFw6x0ygejLPBCuRl3woYZZHBL/ZLNAaDJCOiJ2w0+iD9bZhyiS4gkskWoCmooffaC0uNXaOBn+jb9wc4b79vvOM+C3xOFtVETLbs5+tK0A/9zgmX34mzyWfCR+pByiQny0NFeuzV9m+Qjt+j3yNfnlyKK7vKgltOJQZB0Pte6lh3A7X/fxqnsWwLJpE87Zr6d7ku0pHOcW5gbx88hi73nZQumW9aeTtJhCG2gWqOpaIk9kBcLF0g/1BRrOkWJm4m+SI+O5rBw9x88A2cEyqKM3EM8FTJWDdrGyDc7lbzhSwUvTx6yy0VNJRBK6jZvxG4Yg9yTlNAgmf2b4Km/mA62R+CGLdGttCyVASyf7iSKoaDi86IN0Us7nL3E1BN/Yynt7mWYn7UU7YC59nL8LetrHUUF6laniJwA1oQS56gs2XnhHXAknyDsccMrWwNv/Z0gJ8SW4Mf7EXgBvRTJ79H/E2tw3WB7X46eRUjhMnOt2JpfPrMp9hOl8IPoMSYL50Ep8FZdFt8y3lFNzPJTlngBLSbb6SeZe02FYXPkdT+DOCy97SEQuGxe7jGEOBwOcl0vA5XYn3KUXKVeKFeIp04SWel1JvaQ8/nLeJWUI9ZTu9338MA7IXnIWT+EQ5zNqUqiCUoQ75d7BWeSBqTFtsO9Knn+yNrMpXO0Wu9AAemOaCYuH94TmuHOyL4tgUu13azv8NJwOe/0os4btKSx3f4xZ5eHaXjPh3oC2MBV+kp7gpDEFach30E77kljgwGC41F2YllQB/m+fDMvgp30z02coa7hyR056viLjMnoIcuxoeoefwoagKWkjMkhzkMFzaGwmwtEiA/EDxt0Aqk+l0xo8Hk53gZhzgp8rVLBqZLSwiLwba88vl6agRXcO2Nv2DZzbaq8yx3yK+euOF0klnwan8VdDNJ8Jx6DZ7z7ZGqir8gAKghC1Ee+GxtNw+NDzHV4pvoJiEJDgftgNRoI46GEagWQlO1EJuwRd3hMC90gKhXsuWoF7SLRiNhwhLxea279afYGTBNT4eSZm7hNNRPJxE7+a7IivqRLyTbhDZ+ItnICgJnOJDvrU4S2yrdKUHsQW4D9kYHsI3+XWybh5F7hMGkr2ZWzyQZyGarm7bnp7kX44eKx3sQ4lofzshJeE7mF+4H2bzNJyMIrndtrNSjFBNfgiyhaHEbSEWFNj6Hvvi78Z3UuYIw+Bq2AvQIFGdB1uiVnHdkEvuz3vtw+Ee6anQM7wtiImrJnfCZ4UXYhfrYks0ZD29BRrhzHnCrjgAe9Aj+FGoPSKJBoAxFEzybAfVwWbxIv9TYEW3YqPfsrvxCfIyOIK38PPkKEstcpKwjRzN3OfPy2NQNm22edPrB+ahkmiMPej8z19TmJJQCvbK3w038mY4BpXhptveSi/5SvID8KcwjGgstgVxtjl59QMuPlHZLiyEY2APQAKz+hdMRO64PihN3sTvth+AWVIb8czBHiAtuq48AVcTbdJ2yx1zEzjThwQCnc2sJNxP3AE70Z+5wagdSiMagmvEUNy/6AyoBuaKm/ktQpEYqYTRV9lFuBW1FOzG5fnO8iXLaeInH0rVZbJ4Vu6KBHqy7Ub6c/8IVAxh+21HVGALPzrpKfgesQhu4OvDXmgFO9D2SxrGf4MSaCRsJMoYCqraJuSP8e/gFHmRsApGwlYgDISqfWE8apPQBTlljT9m1+B3cYvYfl8n8CXuA1yEm4mDpGaWmpY74LC/ghiBLmdm8b9M3WE/+iNLIBPqRXyVokgCMwVvDRd9F1L5WGG8WEapRTdgx+JirkywEu9gK8mTrDuJXvxNambgN7tEtqC3NLL9lz7Nl4KuKi6HzVE98JI7bcoHm5IHwAX8N5CCrjNtbD+kmdw/cC44wN8n9gjxoJetfME9L8GGym2EufBvEA8+S59xLIxCdZIdKEp+zJ+z63CeCMS7kQSYk3QMLsE/hZ7SV3O4dQZ44W8qlkDXM7M4i/UXmE6XYL8qldBcYol0kCyHY/P/A2elhgLkzvNlxNtyBfp1oBfe70oCU3AlhoOvrVuIztwB1wD/3wGXHILKuUPs1TPKeuqiZYrsKO8Y59/DLrMMBaWtSXAYfwPEobKMxfZSimUDMAnU51PI7/x3qbj9RD4sOhoYDd/yA+BsI5VzJAl/BY1QvLk+qinf5cs4DsDKokf0xTcCkrk1nIb3C/WkDMtfttKgnv+HcFK5nkmwB21HwX7axPRSChSeKC05qato54mHYLpUiX/LruVXCQVyOTrO3waXpgnQF1v8Q2Cs7RQRwrakj3ld3tfwl1LC3d9ePyN47JoSrpDOPx19fQuZ79baQLaVgg7e8A1qGUi2nZNCmHVQlKK57WQyP016bk/Jq30yw3cKLOHjoM2g0+nSIqyCiihgeaX8B4/yHRz9YJ4wQXQkv5ZSbDdAL9xIOCY2th61J0hu72ahv3IlcxYzxJ4FrtMbAh65lVKB3CJeoApRsdxC0FAqwzrYZXwTYbVcmb7qScFV6NEgA//w1IJ9bVFkd2Ycbfb0ytsLryl36OH2mhl04nqFl7c5Bzlbem8FBtuOSFPtd0FlI63qorc+my1PGhsYAxOlXqxKXmCfiTccnw5d2C0V9QVh/Aej7/ZKk6XueBd4p+jWE8p9mMavd5SGk4QyYnfzAWm6fTxIxiv4aDHUdtGxUIQFP/kw5XxmZ2aZwUU13O5AnPyf3InMEu0GXSecmAQeiXMD35kb/HV+mVyTDivqiDmXDCjcwnMfDLGtIYcx0+jRRXMjRsEryjb6q+17+kFztjJNPugcQAzxlGZa2cZII+w3QXFeNKhiui/EdkCaGOgHX4lV2O3kSmaGuNhZPjrY/HgBBW5zCISATdJeicBLwC3ls3WxchXu4vY7PGCasEo4ZBkvhTkiQCVcixeFjbYtzlix9LG+fE1Fz6zLemxZgHJvCnyGr+SR5FzR7WLQvgInqCghP8fuFm4Y06Y63dgzC/NUBszEt7xfwCOrShayYXSMN/TIcFgRtaAvWU+n17EGlL/k+s6m5CLPaPaZZZ9kspWHTv4LaIf8frv1nLSOmQ8jpVz2PfEj0Ec86YyLS8gt5dkKQvgAGAEuSyVAu/9Xg3eWlcozeJF1OOaCe0Ks0Mg6UbptMPRzpHJzBcG2ztlX/KP5Y86tgMwWfG3zKZDqbsrScopiJd+LHPURPfEaHpb+CEzmn4ipQpLy2xXuv4hrkr/gX3gfMw3GmA+S+byfYgMjvF6DGSq5jpv+St9tTkYf5PP2U+RL72F+U1Is6Jw4C57lx8KNaAXT1hwBGhnTtAJI41c7tzOCGOGsE7PGUyOwDKr8M1AOWkAPsAL/AA3RdtPfSgu5IxtqzwanxHJCukWX7LZx4CoK5acIm2xDHB2kGjvL8H7Fn1lXnJQ/C/6mvYaniqHtBCstIrvhKKYC2ARKs26xI0gWXyj7XRPZbmqc4758AccIE+Q7BxuQO8VJRCN+gcHed9A0Uo+tkr67KUYvFM7akOobyBXbF1wE930tZU6IkL+iWnxI4nYj5YPyXXBT+MP2hiuSoH3KsR8ByFVTPhuZ/AUeARfAf7gAdkHvohPQBtnBTrMEQHnQUWiUnAa6xFPwGrppJFRxG7ZuBPNP+ngnys1sA04zWXJPuqGUghaiO47hoJMziOdyzcEmuJUngRMekAaiZ1Sc8FLtaT6rUGo1Ywu0BdobO3iYraVYXBqtTMNrnIGTS9KmBSbjA6hV0juSZkeBSowIWf63XFmqqIzFO4Wex9LgYKArX+FD6WdsTXE9yLWEedz8H+IUNEmKkBsq4XAIXKzuleejEZ6NKFo5z5ZrmQIZ0EjsfOIcOOkfIb9GZcUfQrR5cfRdmOP5LPjQ3swpsITQSblO+UEZXBH/ay0N11ri1U38ULBezhPT4A14AhSiD6RF6qcdiExC+9XucApqzP9y9IZtYn5J4dCF3uLvtj6eGmk6/wunYz13FtmUT4WhvCQvkA4qNLio3MSPxPdFH2E27IGKKS/AWm8HMBBGR7UIxIhDQHs8BnyX66JF8AEso/2lbEBmpjg+pZznhhV+hq1gurTM3wOO5JcrjXFvYwsaG/3ba1L2+pdJcXh9Zgv5i/Cf0pJsC/fjcfhq0lo4IXao6uOPgFpKR/APPAvHQhU1J3uBIZr1RFOkq5chg2oJpOMB/JY3GZDyTlRL3WX75AmkbhebqhtwXlEIeYnfAU/xE5TF4JrCgiqoktpBsns7yT/hSrRLmQv3B3rCUnI/z3RmjxQJT+NrYL8yFO2FIXKm9kCZh5ZzHXF91IoXvAvl2TASrGPzYG2xLhqNm4PWUtPCSKY6uhNYY+Tr/MwU+ZjQFFmIlvALPoH7xX+Hk47+qYbxr8FbuTL8F0bAh/AQOkwwoITWI3e8kquuhweRwIc6PsDi4ePAZplBD/FZx5TCF6km6S1egWt6sogIYZzBQ8eVqSBHOQfuK5/wDXG6xy2fgdOQT9kMxwWew/5yDf9ipib4Az7GT41NejiaBqvI/TS/8V1nfgyOQAv4l95bcg84y5gAF+Fyg4KCuCc4JV3wfGFSUZCJMjhpTmZF+Twfhg46T4J66nXcPmGwPLLxQFVgR8L58mq4EHYEDeSpqA3xAvylZhwZKv+lfgeD0RpuuL0ARieTYJpciDbjUHLn8dZp1YwNKwtfKvhsXyCUgvX4dQorDVdOgJFKAW4umouqyVZIorlKLzjZV0teJJcOOJnnUhMI8XnwWklEafAn7KV1VYag/vxoowb3+fVes/IT+EAf/i7sINZBkeoMkAyaeLuxaWgWtwROwusyd8PHXDnkcdYCFdVnmEraJ4clpKi5TBk5XOZhDUhJleWOqDbxFtRXsxKWwkFqX9AYrWEq2eLgN0t5UEzehjri81SvmEDaHvEvnIaP5D41PxHmgBZchlJOTFbmg3fyMLxI6FH4Di4CpVBT5TlwFL2AC+U9/jZMrLQXDMNHwGVFUMLgE5ihfTVu7B+uGy6FJgi/PVuUpeCjkdm3ISO8V8ap68Fs8MAzlQ1BV/lc2B6fzLwEPjDZykTirMhjCW8w9ZNzzL/xucBU+TycCKcDs2hwL+pIzAP3MGN7DFqpnaQC5bSvk6UdCHN0lErCfqgYrkPHJJnSNSEdh+JAy/XxD4VFUjojynO4Z7IbTJDr4gf84PxTcKS0WVkhW8HRqIHQJDfzDmSuC0ckFnUEq5Ub8mewABbTVsuz0VGmFs5TionDC0TlX6kifMnugDP5ucpUdZIx5X8WbWc6K1nCRFgMn81cJfXzbZOnksOFGNwFX7Ssg73s6/AF/3y5O9wP2kt9haCR8i+ISKkQ5xKTQW11hVBB6Zp/2tJFLEV+FtKkb8oOtJLWzNvSl/D30VlUZD4baxb8wi7PG/ifZ6zMSnegbCTRy51T4GahvCLAPLGDdRHYAq8fFAOh/HIhX1kl1VXOGnlwDcxSK8jjUBNvPkpSOor147KUneI58C3QGH4MaHIb9ZvUE+TlT/XVkB8ZvboS3cksEn5GH4QMZeMnIxal2NcAO1EVT/I2kY3niWuFQr4SoJUd5Ft+Ps6mXOAc7sBlyMfiK9tNfILLwS/mqyuR6G8623Yt/TfzB0pFpL276TdXls+P/wwmJJ2B68Suxo7WmI1K+gWOcH3kS6C0cNwZK10DkZY878nANraU7BKz5TuAAgS4jBfDNDQytg06ITcSN5s3yXMEG+jt3QAeH3kDy6gLxLUSEbU16g6owLUBNdDDzDQ+13YKtKQz2MFKBfTMSUgPyO2oX343KEg2/ijXib8qLpA7U4eYdlh0hYNsXN7fFw401yIszD56eCAlUEfOUGq5azhKZKz1jlPiFc25xH47UMogu2fSTVtx+Io/C6qhbb4Z1glgXWAO1KS63D/kcqE46OHo2KhUYV9PLrjIn4XRYLa0VorErw0u2mm5rlSWRwmfbCvhaD5fbH2gGuhgi4USLi5YxBGm/rbjUmV/utRHeZg5mm1CdAJV3b0CNeRzcgRZSizn6oVuRIwEa8TtgRuB9XxN8Ta8R8V4w7CVrgtseHLOQtDN+pK86m/rTsyfcsIFe8smN+MIyYjcP0D+AC8QFwhvYWtvjLOT2MtpB8vZgeBPpVKe1QGlWO86ECs+DdCu8fwMcRBR23w/fHbiHsnCOeBhaYsYIm1HaYBSPtpSlSx4gl/rWAu6c1f4haYRUpaTAUvxA66TUNzS3ZksfsvbJP6W/81MYZYTK8Fh93R/OjTLS8nXwldqMpoRdV46Kbbx7w+UFN0ihv9RVw26/s81FzhxaOyfYI+5s6sTU9+95vDimOIwTeboq7bKGVp8c3kV3EOEUrWil3vjiMeCiXghHWWGgVZKsSM/HC6piy8cDBb0wC/qHScIu4lullN5y0wdJYVtbGw4QXGmNAjxUinltLWeYofZfEvHL2kQt4v7Yd0jlXY+AaPxOK6WoCeOc74XppxoaRDN+cztTIKjB1TdE5gt8JncgcwR/yKL4c751QzaTWcG8IlgmjRFvkbhQGX1OHkWbsZR3o3w2YlNrs58Hfqc9/8aLvfvnus4jrt/OYSY6bAlxTGkLLv7biybS/Xt4rJpGYZyOe2Qy2ySmUlOmSI7qySdMmSNNVv2/X4/3+/3fXndvi6NCOkkxz1JKUR0+f4Dz1+ez3Mez8cDDV69zvYYMWZHODu6rtSuNGNTL6f3GrjNdypNBU+nbVTFTq7uBZsabie3VC7nivovsNQ/LD3duRGoTWsxrHtT35plqsoJ6FI9Rc1VX8Agtc3W7QpG/lOtMyzpftXsJDtFKWtVTOJdPSWyorGBRXvPJ68OxX+9V00A7Yl1MgcNNzMyBwY22tcgLb2jWp4aQ+59H6li84HvFXVY31LWns44H1jEOumWnUDizLDlzsSMRaEO6VucJmeW/Q3au60/KzvfPxbnQWLDg+6ihqpganysKkuw+o/gcHsX2vtufJWtlyg0N0PxwbqUqcGjwZ7xW2oXO6N9883BYFTEcc9GftEK7KuLoNx7AN603zof1/+tJoQuB3KS9+gKX1sYh53UtUBbX0ljmX7bf9bMRK/nw2Csf5CNGnlEzcY7OM5do99u+IRH+cfoMjjkT7YumGQPQmJGqa6RPr7puJ47aIEqPTet3kxPSFH79HFYSL882ktNy1qrijiWooPO4+u81doVyNGfe9+0vfQp6EFRwYtOvlW2Fo7oIfpa0xldqiqcxqYH1O5QDbTWVZCP120eHKJKswjvBTLpILwVWODkmlw9Vfl2zLZRemGElxXmqdDWQE3gmN0VvAPHqdaToAtD7eCH4Q/bJEYuSOgBEwKvS3EwwXyDjzoAcTgJf8Tb7nu2OJyvimmlHLXdaBbsaeyHJ/3Pma7YkroJOElm9pNoT0kz9zFznEbnBcg250xH1Qwv2DMYz8/oVH0YNuNkGg/tYJnaCmUmzkwOlpifTAWNsyfxDPXBfzFGMqGEMvQa9mJyKEdlR0h3Ua/xb4d82EcL8TQ0qiHqqmqF1WoBdZVVnoftMl0O9+/pBiOlUv7yd8duoXrpqp+3rbna/zEexya6QDNT7sKv4dG2H7cLt8d8fh2bg1tplNoMcXyTtLh1fxg4sgALw6vlOTigYkI1qCAZelovhmAVdZBbxrEncDu55CAWYbmNoWrYAdcjNvMv3CeboZ6ipC915XdlAbbj721reZlG61JTZa39xOapQryE5XwFY+gf3d24bDkW6DG8UXI9O2GbHYE3nCSYLzelQN9BG8yRs2Y8HOaVgQVUjT4+wOv93anN/ptwhmPDY/Eqr6GfzVI+p6dQilzkLuG+8BUmugsoNrxbinGDKTPr6RCm4FGoo2+wAz8pc20CduF79L4YWkMPYQ4PpiWYCVfhZ8yTF3EWL5GhXM3fSS55eAgOkuu03Z6EPOgPLky3cRTNreQoXaaXrBsq4RecZd/jU1LiMTSdsrhSJ8Jb4flhA6M40Z7mVbgWFgpiG6nHOeIXl5nHnv3T8BkZH66CYXKXvJAgj9jFfEzekEuSjptknPdpfijSQU8eqp7FpTyYm2k5xouPXTJXDuAQcssd/ii8XDK4jjpJD/mUzsJGjKZ4qSCP5MinvIz7hZvwBANukPv4BE4ihV9CLX+OAeosT0iGtJU20AVb0GHKxf5spNAzmJfQcDlp3oFv5Zq0xgH8e8RiPsME7Cm9eRN3xEOMYuxj3Bw+Z6fIxPCrmCXHqTPG8QW7l2dGcnOkCp6S86HJtEUmSjJV7/sdoziXPqRCcMmfkZ5ayLvoggHchW/LTllHe/EIX6A8zLEvYw2spaTIzqLlIhVStpTANEml3dKZb+BoXIEbIJXn0Bu4lN7j3lJA/aEeKjGLBsB/NFT+B/OY/bJ+SAAA
|