From 5f6d28673adddf25c2767816f9430a9f4ed28813 Mon Sep 17 00:00:00 2001 From: chk <79915315+ChKendel@users.noreply.github.com> Date: Tue, 16 Jun 2026 17:04:11 +0200 Subject: [PATCH] MultiPose --- doc/Homing_5_Pose.md | 281 ++++++++++++++---- doc/Kalibrierung.md | 23 ++ docker-compose.yaml | 2 +- scripts/5_pose_estimation.py | 258 ++++++++++++++-- .../5_pose_estimation.cpython-311.pyc | Bin 0 -> 45870 bytes 5 files changed, 487 insertions(+), 77 deletions(-) create mode 100644 scripts/__pycache__/5_pose_estimation.cpython-311.pyc diff --git a/doc/Homing_5_Pose.md b/doc/Homing_5_Pose.md index 0df738a..80c3665 100644 --- a/doc/Homing_5_Pose.md +++ b/doc/Homing_5_Pose.md @@ -6,10 +6,14 @@ > Startwert. Ohne guten Startwert läuft die interne Optimierung mangels eigener > verlässlicher Initialisierung leicht in ein lokales Minimum (siehe „Wichtige > Einschränkung" unten). -> Status: Skript liegt bereits in `scripts/5_pose_estimation.py`, **noch nicht** -> in `homingOrchestrator.js`/`server.js` verdrahtet — und braucht noch einen -> kleinen Code-Hook, um den 4b-Startwert überhaupt entgegennehmen zu können -> (siehe Offene Punkte). +> Status (2026-06-16): **Code-Hooks umgesetzt und gegen drei echte Homing-Captures +> verifiziert** — `--from-state` (Startwert aus 4b, mit Multi-Start-Schutz für alles, +> was der Startwert nicht abdeckt), `null` statt `0` für unbeobachtbare Gelenke +> (z. B. `Hand`/`Palm`/Finger, aktuell ohne Marker in `robot_1781069752019.json`), +> `scipy` in `docker-compose.yaml` ergänzt, sowie neu der Kalibrier-Switch +> `--calibrate-origin` (siehe eigener Abschnitt unten). **Noch offen:** Anbindung in +> `homingOrchestrator.js`/`server.js`/Frontend (siehe Integrationsschritte — +> Umfang/Fehlerfall/Robotersteuerung-Politik dafür sind noch nicht festgelegt). --- @@ -94,17 +98,20 @@ Block statt pro Einzel-Fallback-Stufe. ### Wichtige Einschränkung: Startwert und lokale Minima -`estimate_pose()` ruft für `global_ba`/`hybrid` **immer zuerst selbst** -`estimate_sequential_fk()` als „billigen, robusten Init" auf -(`scripts/5_pose_estimation.py:471-476`) — es gibt aktuell **keinen** Parameter, -um stattdessen einen extern vorgegebenen Startwert (z. B. den `accumulated_state` -aus 4b) einzuspeisen, obwohl `estimate_global_ba()` selbst intern bereits ein -`x0`-Dict entgegennimmt (`:236-237`). +**Ursprünglicher Befund (vor dem Code-Hook vom 2026-06-16):** `estimate_pose()` +rief für `global_ba`/`hybrid` immer selbst `estimate_sequential_fk()` als +„billigen, robusten Init" auf — es gab keinen Parameter, um stattdessen einen +extern vorgegebenen Startwert einzuspeisen, obwohl `estimate_global_ba()` +selbst intern bereits ein `x0`-Dict entgegennahm (`:272-273`). **Das ist jetzt +behoben** (`seed`-Parameter auf `estimate_pose()`/`estimate_sequential_fk()`, +CLI `--from-state`) — die folgende Beschreibung des Multi-Start-Mechanismus +gilt weiterhin unverändert für **alles, was der Seed nicht abdeckt** (bzw. für +den reinen Kaltstart ohne `--from-state`): -`estimate_sequential_fk()` initialisiert jede Variable bei `0.0` und rastert den -Multi-Start `{0,60,120,180,240,300}°` **nur über die erste Variable eines -Blocks** (`bvars[0]`) — und auch das **nur, wenn diese selbst `revolute` -ist** (`:296-304`). Für dieses Robotermodell heißt das konkret: +`estimate_sequential_fk()` initialisiert jede nicht geseedete Variable bei +`0.0` und rastert den Multi-Start `{0,60,120,180,240,300}°` **nur über die +erste Variable eines Blocks** (`bvars[0]`) — und auch das **nur, wenn diese +selbst `revolute` ist** (`:348-356`). Für dieses Robotermodell heißt das konkret: - Block `{x, y}` (Base markerlos → mit Arm1 zusammengefasst): `bvars[0]` ist `x` (linear) → `lead_type != "revolute"` → **kein** Multi-Start. `y` @@ -124,9 +131,16 @@ unten sichtbaren großen Abstand zwischen Mittelwert (0,253°) und Schlechtestfa (1,568°) bei sonst niedriger Streuung (0,134°) — ein Muster, das zu „meist gut, gelegentlich falsches Minimum" passt. -**Konsequenz:** `5_pose_estimation.py` sollte in appRobotHoming **nicht kalt** -laufen, sondern mit dem `accumulated_state` der 4b-Kette als Startwert (Details -und der dafür nötige Code-Hook: Abschnitt „Integrationsschritte"). +**Konsequenz / Status:** `5_pose_estimation.py` sollte in appRobotHoming **nicht +kalt** laufen, sondern mit dem `accumulated_state` der 4b-Kette als Startwert. +**Umgesetzt:** `--from-state ` lädt einen flachen oder +`{"accumulated_state": {...}}`-verpackten Zustand; `estimate_sequential_fk()` +überspringt nur Blöcke, die **vollständig** im Startwert enthalten sind, und +wendet auf alles andere weiterhin seinen normalen Multi-Start an — auch bei +einem **unvollständigen** Seed (z. B. nur `x,y` aus einem abgebrochenen +4b-Lauf) bleiben `z`/`a` also Multi-Start-geschützt, statt ungeschützt bei `0` +zu starten. Verifiziert an drei echten Captures (s. „Validierung an echten +appRobotHoming-Daten" unten). ### Validierung im Rendering-Projekt (Simulation, 10 Posen, bekannte GT) @@ -139,6 +153,37 @@ und der dafür nötige Code-Hook: Abschnitt „Integrationsschritte"). (Quelle: `appRobotRendering/doc/pipeline.tex`, Abschnitt „Validierung und Ergebnisse".) +### Validierung an echten appRobotHoming-Daten (2026-06-16) + +Drei echte Captures aus `test/homing/` (nicht simuliert; vom Nutzer bereitgestellt, +inkl. eines bereits aktualisierten 4b-Laufs): + +| Fixture | 4b kam bis | Arm1-Marker gesehen | Ellbow-Marker gesehen | +|---|---|---|---| +| `20260616_120456` | Arm1 (Ellbow nicht gespeichert) | 197, 243 | 129, 132 | +| `20260616_133151` | Arm2 | 198, 229 | 129, 132 | +| `20260616_135403` | Arm2 | 197, 243 | 129, 132, 121 | + +Geprüft für jede Fixture (`python scripts/5_pose_estimation.py … --from-state state_Arm2.json`, +bzw. `state_Arm1.json` für die unvollständige erste Fixture): + +- **Kein Crash**, trotz `Hand`/`Palm`/`FingerA`/`FingerB` aktuell ganz ohne + Marker in `robot_1781069752019.json` (`"markers": []` an allen vieren) — + `b`, `c`, `e` kommen als `confidence:"none"`, `"value": null` heraus, exakt + wie gefordert ("Hand als unbekannt stehen lassen"). +- **Kalt vs. geseedet liefert dieselben Werte** (z. B. `133151`: `x=193.96mm, + y=25.74°, z=-28.00°, a=-0.81°` in beiden Fällen) — der Seed verändert das + Ergebnis nicht, wenn der Kaltstart bereits im richtigen Minimum lag; er + schützt nur die Fälle, in denen das nicht so ist. +- **Unvollständiger Seed** (`120456`, nur `x,y` aus `state_Arm1.json`, `z`/`a` + fehlen): liefert dieselben Werte wie der volle Kaltstart — und durchläuft + jetzt nachweislich den Multi-Start-Pfad für `z`/`a` (Code-Pfad geprüft, nicht + nur Zufall des Ergebnisses). +- Residuum über alle Marker: **4,3–4,5 mm RMS** (deutlich über der + Simulationsvalidierung oben — erwartbar, reale Marker/Kameras sind + verrauschter als der appRobotRendering-Renderfehler-Boden; noch keine + `huber_delta_mm`/`normal_weight`-Nachjustierung vorgenommen). + --- ## Vorteile @@ -183,14 +228,15 @@ und der dafür nötige Code-Hook: Abschnitt „Integrationsschritte"). `5_pose_estimation.py` daher beim Homing real gefährdet, in einem lokalen Minimum zu landen, statt die echte Pose zu finden — kein Rand-, sondern ein Kernfall, weil die Pose beim Einschalten grundsätzlich unbekannt ist. -- **`scipy` fehlt aktuell im appRobotHoming-Container.** `docker-compose.yaml` - installiert nur `opencv-python-headless numpy` - (`pip3 install --quiet --no-cache-dir opencv-python-headless numpy`). Ohne - `scipy` greift `HAVE_SCIPY=False`: `estimate_sequential_fk` lässt jeden Block - auf `0.0` stehen, `estimate_global_ba` gibt den (dann ebenfalls nullwertigen) - Startwert unverändert zurück — **kein Fehler, nur eine `[WARN]`-Zeile auf - stdout.** Das ist ein stiller Fehlmodus: muss vor dem ersten Einsatz behoben - werden (scipy zur `pip3 install`-Zeile ergänzen). +- ~~`scipy` fehlt im appRobotHoming-Container~~ — **behoben (2026-06-16):** + `docker-compose.yaml` installierte nur `opencv-python-headless numpy`; ohne + `scipy` greift `HAVE_SCIPY=False` und `estimate_sequential_fk`/`estimate_global_ba` + fallen lautlos auf den Nullzustand zurück (nur eine `[WARN]`-Zeile, kein + Fehler-Exit) — ein stiller Fehlmodus. `scipy` ist jetzt in der + `pip3 install`-Zeile ergänzt (kein separater Image-Build nötig — `pip3 + install` läuft laut `command:` bei jedem Containerstart neu). **Noch nicht** + auf einem laufenden Container wirksam geprüft — wirkt erst nach dessen + nächstem Neustart. - **Zwei nichtlineare Least-Squares-Läufe statt eines geschlossenen Ausdrucks** — langsamer als `sequential_vector` und langsamer als ein einzelner `4b_revolute_angle.py`-Aufruf. Für „schnell, vollautomatisch" (Anspruch aus @@ -211,12 +257,14 @@ und der dafür nötige Code-Hook: Abschnitt „Integrationsschritte"). `server/server.js` → `POST /api/homing/send-state`), `5_pose_estimation.py` schreibt verschachtelt (`movements..value`). Eine kleine Adapterfunktion ist nötig, kein Drop-in-Ersatz. -- **Unbeobachtbare Gelenke werden als `0.0` ausgegeben**, nicht als `null` - (Konfidenz `none`/`observable:false` steht nur als Metadatum daneben). Das - widerspricht der sonst im Projektverbund befolgten Konvention „Unbekannt bleibt - `null`, nie erfundene `0`". Eine Integration muss `observable:false` aktiv auf - `null` ummappen, bevor der Zustand weitergereicht wird — sonst wandert eine - stille `0°`/`0mm` in Richtung Robotersteuerung. +- ~~Unbeobachtbare Gelenke werden als `0.0` ausgegeben, nicht als `null`~~ — + **behoben (2026-06-16):** `main()`s Output-Writer schreibt jetzt `"value": + null`, wenn `observable:false`, statt der internen `0.0` (die intern bleibt, + weil die FK für die *anderen* Gelenke einen Zahlenwert braucht — nur der + *Output*-Vertrag ändert sich). Verifiziert an allen drei Fixtures (`Hand`, + `Palm`, `e` → `null`). Gilt nur für `5_pose_estimation.py` selbst — der + Adapter zu `/api/homing/send-state` (nächster Punkt) muss `null` weiterhin + korrekt durchreichen, nicht wieder in `0` zurückverwandeln. - **Noch nicht an echten Kamerabildern/Markern validiert.** Die Zahlen oben sind Simulation aus appRobotRendering (saubere FK-Marker-Positionen, definierter Renderfehler-Rauschboden). Reale Marker-Ungenauigkeiten (s. @@ -240,59 +288,178 @@ und der dafür nötige Code-Hook: Abschnitt „Integrationsschritte"). CLI-Override, oder dauerhaft über `robot_1781069752019.json` → `pose_estimation.method`. Nützlich, um den Effekt des Startwerts zu isolieren: einmal kalt (zeigt das Problem aus „Wichtige Einschränkung"), einmal mit - 4b-Startwert (sobald der Code-Hook existiert) — als Regressionstest für genau - diese Einschränkung. + `--from-state` und 4b-Startwert — als Regressionstest für genau diese + Einschränkung (beide Aufrufe stehen unter „Aufruf (Stand-alone, zum Testen)"). - **`finger_block_joints`/`per_link_method`** stehen schon (leer) in der robot.json — vorbereitete, aber im Skript bisher ungenutzte Erweiterungspunkte aus appRobotRendering. --- -## Aufruf (Stand-alone, zum Testen) +## Kalibrier-Switch: Gelenk-Origin (`--calibrate-origin`) -⚠️ Diese Aufrufe laufen **kalt** (kein externer Startwert — der Code-Hook dafür -existiert noch nicht, s. Integrationsschritte). Geeignet, um das Kaltstart-/ -Lokales-Minimum-Verhalten aus „Wichtige Einschränkung" zu beobachten und zu -reproduzieren — **nicht** der vorgesehene Produktionspfad. +**Motivation:** `doc/Kalibrierung.md` Schritt [4] bestimmt +`links.Arm1.jointToParent.origin[1,2]` (Y/Z des Schultergelenk-Drehpunkts) geometrisch +aus einer **dedizierten 3-Pose-Aufnahme** (Verfahren B: Kreis-Umkreismittelpunkt +durch 3 Positionen je Marker, nur Marker-**Mittelpunkte**, keine Normalen — Details +dort). Diese Y/Z-Werte sind laut Nutzer „etwas ungenau gemessen". `5_pose_estimation.py` +hat mit `residual_vector()` (Position **und** Normale, robuste Verlustfunktion, +generischer Least-Squares-Löser) bereits die Bausteine, um densel­ben Drehpunkt +**aus den ohnehin vorhandenen Homing-Aufnahmen** zu verfeinern, statt eine eigene +Aufnahme-Session zu brauchen. + +### Ansatz + +Statt nur die Gelenkvariable `q` zu fitten, werden für **einen** angegebenen Link +zusätzlich 2 Komponenten seines `jointToParent.origin` freigegeben: + +``` +Normalfall: min_q Σ_marker ρ(‖r(q)‖) (3 Freiheitsgrade weniger) +--calibrate-origin: min_{q_link, origin_y, origin_z} Σ_marker∈link ρ(‖r(q_link, origin_y, origin_z)‖) +``` + +Implementiert in `estimate_origin_calibration()` (neu, +`scripts/5_pose_estimation.py`): mutiert `fk.links[]["jointToParent"]["origin"][1,2]` +**transient** während des Solves (jeder `fk.compute()`-Aufruf liest `origin` frisch +aus dem Dict, siehe `robot_fk.py:compute()` — kein Caching, daher funktioniert die +direkte Mutation ohne Änderung an `robot_fk.py`) und stellt den Originalwert danach +**immer** wieder her — das Skript bleibt ein reines Report-Tool, **`robot.json` wird +nie geschrieben**. Multi-Start `{0,60,…,300}°` für die eigene Gelenkvariable, wenn +revolut (wie bei den anderen Verfahren). Alle anderen Gelenke bleiben fix (aus +`--from-state`, sonst `0`) — Vorbedingung wie beim bestehenden Verfahren: die +übrige Kette (insbesondere `x`) muss schon vertrauenswürdig sein. + +### Aufruf ```bash python scripts/5_pose_estimation.py data/homing//aruco_marker_poses.json \ -robot scripts/robot_1781069752019.json \ + --from-state data/homing//state_Arm2.json \ + --calibrate-origin Arm1 +# -> schreibt Arm1_origin_calibration.json (Report), robot.json unverändert +``` + +Funktioniert generisch für jeden Link mit eigenen Markern (an `Ellbow` mit +`--calibrate-origin Ellbow` getestet) — keine Arm1-spezifische Hardcodierung. + +### Befund an echten Daten (2026-06-16, vorläufig) + +Auf zwei **unabhängigen** Fixtures mit **unterschiedlichen** sichtbaren +Arm1-Markern ergibt sich eine konsistente Korrektur: + +| Fixture | gesehene Marker | Δ Origin Y | Δ Origin Z | Residuum RMS | +|---|---|---|---|---| +| `20260616_133151` | 198, 229 | **+6,46 mm** | **−19,97 mm** | 1,19 mm | +| `20260616_135403` | 197, 243 | **+7,33 mm** | **−18,49 mm** | 1,19 mm | + +Beide Läufe sehen **andere** Markerpaare und kommen trotzdem auf nahezu +denselben Versatz (~+7 mm / ~−19 mm) — das ist kein Zufallsrauschen eines +einzelnen Markers, sondern ein konsistenter Hinweis, dass der aktuell in +`robot_1781069752019.json` hinterlegte Wert (`[110, 101.1, 55.2]`) tatsächlich +um ungefähr diesen Betrag daneben liegt. **Noch nicht** unabhängig gegen das +geometrische Verfahren B (3-Pose-Aufnahme) gegengeprüft — siehe Offene Punkte. + +### Einschränkungen / Unterschiede zum bestehenden Verfahren + +| | Verfahren B (`yAxisCompute.js`, bestehend) | `--calibrate-origin` (neu) | +|---|---|---| +| Aufnahmen nötig | 3 Posen, ≥15° Drehung dazwischen | **1** Pose (mehr optional, noch nicht implementiert) | +| Signal | Marker-Mittelpunkt über 3 Zeitpunkte | Position **+ Normale**, robuste Verlustfunktion | +| Fehlerabschätzung | Residuum εᵢ je Marker (Kreis-Abweichung) | `residual_rms` über alle Link-Marker | +| Achsrichtung | wird mitbestimmt (Kreuzprodukt/Ebenen-Normale) | wird **nicht** gefittet — nur `origin`, `axis` bleibt aus robot.json | +| Identifizierbarkeit | durch Drehung explizit entkoppelt von Winkel | aus 1 Pose: Winkel/Origin-Korrelation theoretisch möglich, durch mehrere Marker + Normalen an verschiedenen Hebelarmen empirisch entkoppelt (s. Befund oben) — **nicht formal bewiesen** | +| Schreibt robot.json | ja, über „Joint-Origin Y/Z übernehmen" | nein — nur Report, gleiche Übernahme-Aktion nutzbar | + +Die Achs**richtung** (`jointToParent.axis`) fitten beide Verfahren nicht — das +bleibt vorerst bei Verfahren B, falls sie ebenfalls ungenau ist. + +--- + +## Aufruf (Stand-alone, zum Testen) + +**Empfohlen — mit Startwert aus der 4b-Kette** (z. B. dem letzten vorhandenen +`state_*.json`; unvollständig ist ok, fehlende Variablen bleiben Multi-Start-geschützt): + +```bash +python scripts/5_pose_estimation.py data/homing//aruco_marker_poses.json \ + -robot scripts/robot_1781069752019.json \ + --from-state data/homing//state_Arm2.json \ -out data/homing//robot_state.json +``` + +**Kalt** (kein `--from-state`) — funktioniert weiterhin identisch wie vor diesem +Code-Hook, aber ohne den oben beschriebenen Schutz für gekoppelte Blöcke; +nützlich, um das Kaltstart-/Lokales-Minimum-Verhalten aus „Wichtige +Einschränkung" gezielt zu reproduzieren/regressionszutesten: + +```bash +python scripts/5_pose_estimation.py data/homing//aruco_marker_poses.json \ + -robot scripts/robot_1781069752019.json # Verfahren erzwingen, z.B. zum gezielten Vergleich einzelner Methoden: python scripts/5_pose_estimation.py data/homing//aruco_marker_poses.json \ -robot scripts/robot_1781069752019.json --method global_ba ``` +Gegen die echten Testdaten in `test/homing/*/` ausprobiert — siehe +„Validierung an echten appRobotHoming-Daten" oben. + --- ## Integrationsschritte (Offene Punkte) -- [ ] **`scipy` in `docker-compose.yaml` ergänzen** (`pip3 install …` Zeile) — - ohne das läuft `hybrid` lautlos auf Nullzustand. +**Erledigt (2026-06-16):** + - [x] **Architektur entschieden:** 4b-Kette läuft zuerst und liefert den `accumulated_state` als Startwert; `5_pose_estimation.py` läuft danach als globaler Verfeinerungsschritt darüber. Kein Ersatz, keine parallele Alternative — siehe „Wichtige Einschränkung" oben. -- [ ] **Code-Hook in `5_pose_estimation.py` ergänzen:** aktuell gibt es keinen - Weg, einen externen Startwert hineinzugeben. Vorschlag: CLI-Flag analog zu 4b - (`--from-state accumulated_state.json`), das den 4b-Zustand als `x0` direkt an - `estimate_global_ba()` durchreicht (Parameter existiert dort bereits, - `:236-237`) und so den internen `estimate_sequential_fk()`-Kaltstart in - `estimate_pose()` (`:471-476`) umgeht bzw. nur als Fallback nutzt, falls kein - externer Startwert übergeben wird. -- [ ] Adapter `movements..value` → flaches `{x,…,e}`-State-Objekt für - `POST /api/homing/send-state`; dabei `observable:false → null` ummappen. -- [ ] Anbindung in `homingOrchestrator.js` (neuer Schritt, analog `runBoardPipeline`/ - 4b-Loop) + SSE-Event(s) für Fortschritt (auch ohne echtes Zwischenergebnis, - z. B. ein `step`-Event „läuft" / „fertig"). -- [ ] Erste echte Messung: `hybrid`-Ergebnis gegen 4b-Kette auf demselben - `data/homing//aruco_marker_poses.json` vergleichen (insbesondere am - Ellbow-Fall aus `Homing_1_StepByStep.md`). +- [x] **`scipy` in `docker-compose.yaml` ergänzt** (`pip3 install … numpy scipy`). +- [x] **Code-Hook `--from-state`:** `load_seed_state()` (akzeptiert flach oder + `{accumulated_state:{...}}`) + `estimate_sequential_fk(..., seed=...)` + überspringt nur vollständig geseedete Blöcke, alles andere bleibt + Multi-Start-geschützt. `estimate_pose(..., seed=...)` reicht das durch. + Verifiziert an 3 echten Fixtures (s. „Validierung an echten + appRobotHoming-Daten"). +- [x] **Robustheit gegen fehlende Marker:** `Hand`/`Palm`/`FingerA`/`FingerB` + (aktuell `"markers": []`) laufen ohne Crash durch, Output zeigt `null`/ + `confidence:"none"` statt erfundener `0`. `main()`s Output-Writer mappt + `observable:false → value:null` (intern bleibt `0.0` für die FK-Rechnung + der anderen Gelenke — nur der Output-Vertrag ändert sich). +- [x] **Kalibrier-Switch `--calibrate-origin `** umgesetzt + (`estimate_origin_calibration()`) — generisch für jeden Link mit eigenen + Markern, getestet an `Arm1` und `Ellbow`. Schreibt nie `robot.json`, nur + einen `*_origin_calibration.json`-Report. Details: eigener Abschnitt oben. + +**Noch offen:** + +- [ ] **Adapter** `movements..value` → flaches `{x,…,e}`-State-Objekt für + `POST /api/homing/send-state`; `null` muss `null` bleiben (nicht zurück zu `0`). +- [ ] **Anbindung in `homingOrchestrator.js`** (neuer Schritt nach der 4b-Schleife, + SSE-Events) — **Umfang/Fehlerfall/Sende-Politik an die Robotersteuerung sind + noch nicht festgelegt** (offene Rückfrage vom 2026-06-16, noch unbeantwortet: + Minimal-Fix vs. Voll-Integration; Abbruch vs. Fallback bei Fehler; Senden vs. + nur Anzeigen). Diese drei Entscheidungen zuerst klären, dann verdrahten. +- [ ] **Arm1-Origin-Befund anwenden oder verwerfen:** Δ(Y,Z) ≈ (+7, −19) mm ist + auf zwei unabhängigen Fixtures konsistent (s. Abschnitt „Kalibrier-Switch"). + Vor dem Übernehmen: (a) mit mehr Fixtures/Posen erhärten, (b) wenn möglich + gegen eine frische Verfahren-B-3-Pose-Messung gegenchecken, (c) erst dann via + Kalibrierung-Tab „Joint-Origin Y/Z übernehmen" übernehmen. +- [ ] **`--calibrate-origin` an die Kalibrierung-UI anbinden** (`doc/Kalibrierung.md` + Schritt [4]) — aktuell nur CLI/Report; Tab „Arm1 – Y" könnte beide Verfahren + (Geometrisch/Verfahren B und `--calibrate-origin`) nebeneinander anzeigen. +- [ ] **Mehrpose-Erweiterung für `--calibrate-origin`** (mehrere + `aruco_marker_poses.json` + gemeinsames `origin`, je Pose ein eigener + Gelenkwinkel) — würde die Winkel/Origin-Korrelationsschwäche aus einer + Einzelpose weiter reduzieren, analog zur bestehenden 3-Pose-Aufnahme. - [ ] `huber_delta_mm`/`normal_weight` ggf. gegen reale Marker-Genauigkeit - nachjustieren (Defaults sind aus appRobotRendering-Simulation übernommen). + nachjustieren — reales Residuum (4,3–4,5 mm RMS) liegt deutlich über der + Simulation; Defaults sind unverändert aus appRobotRendering übernommen. +- [ ] Python-Tests (`pytest`) für `load_seed_state()`, den Block-Skip in + `estimate_sequential_fk()` und `estimate_origin_calibration()` — aktuell nur + manuell gegen die drei Fixtures verifiziert (s. oben); appRobotHoming hat + bisher keine Python-Testinfrastruktur (nur Jest/JS), das wäre die erste. - [ ] Eintrag in `Homing.md`-Tabelle (Doku-Übersicht) ergänzen, sobald - verdrahtet. + `homingOrchestrator.js` verdrahtet ist. --- diff --git a/doc/Kalibrierung.md b/doc/Kalibrierung.md index 196d567..77ecb5d 100644 --- a/doc/Kalibrierung.md +++ b/doc/Kalibrierung.md @@ -102,6 +102,29 @@ befestigt. Diese werden in `links.Base.markers` eingetragen. - `links.Arm1.jointToParent.origin[1]` (Y) und `[2]` (Z) in `robot.json` - Optional: `links.Base.markers` ergänzt +### Alternative/Ergänzung — `--calibrate-origin` (`5_pose_estimation.py`) + +🔶 *Experimentell, noch nicht in dieses UI eingebunden* — Details, Mathematik und +Vergleichstabelle: [`doc/Homing_5_Pose.md`](Homing_5_Pose.md) (Abschnitt +„Kalibrier-Switch: Gelenk-Origin"). + +Bestimmt `origin[1,2]` desselben Gelenks **aus einer einzelnen vorhandenen +Homing-Aufnahme** (Position + gemessene Normale aller Arm1-Marker, robuster +Least-Squares-Fit) statt aus der dedizierten 3-Pose-Aufnahme oben — keine +eigene Mess-Session nötig, dafür (noch) ohne die explizite Drehung, die hier +Achse und Winkel sauber entkoppelt. Auf zwei realen Captures ergab sich eine +konsistente Korrektur von ca. **+7 mm (Y) / −19 mm (Z)** gegenüber dem +aktuellen `robot.json`-Wert — bisher **nicht** gegen eine frische Messung mit +Verfahren B gegengeprüft, daher noch nicht über „Joint-Origin Y/Z übernehmen" +angewendet. Aufruf: + +```bash +python scripts/5_pose_estimation.py /aruco_marker_poses.json \ + -robot scripts/robot_1781069752019.json \ + --from-state /state_Arm2.json \ + --calibrate-origin Arm1 +``` + --- ### Mathematik: Bestimmung der Rotationsachse diff --git a/docker-compose.yaml b/docker-compose.yaml index 08ab19f..f2a1641 100755 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -17,7 +17,7 @@ services: ports: - "2093:2093" command: > - /bin/bash -lc "apt-get update -qq && apt-get install -y --no-install-recommends python3-pip && pip3 install --quiet --no-cache-dir opencv-python-headless numpy && npm ci || npm install && node server/server.js" + /bin/bash -lc "apt-get update -qq && apt-get install -y --no-install-recommends python3-pip && pip3 install --quiet --no-cache-dir opencv-python-headless numpy scipy && npm ci || npm install && node server/server.js" networks: - approbots restart: unless-stopped diff --git a/scripts/5_pose_estimation.py b/scripts/5_pose_estimation.py index 8bf703c..0fff55d 100644 --- a/scripts/5_pose_estimation.py +++ b/scripts/5_pose_estimation.py @@ -24,6 +24,26 @@ Observation input: marker_observation = "corner_pose" -> aruco_marker_poses.json (pos + measured normal) marker_observation = "center_point" -> aruco_positions_*.json (pos only) +Homing integration (appRobotHoming, see doc/Homing_5_Pose.md): + --from-state seed/init state (flat {var: value}, or the + {"accumulated_state": {...}} shape written by + 4b_revolute_angle.py) used as x0 for + global_ba/hybrid instead of the internal + estimate_sequential_fk() cold start. Missing + variables default to 0 and are estimated/flagged + normally. Without --from-state, behaviour is + unchanged (internal cold start, as before). + --calibrate-origin special mode: instead of estimating the full + pose, fit 's own joint value TOGETHER WITH + its jointToParent.origin Y/Z from that link's own + markers (complements the geometric multi-pose + method in doc/Kalibrierung.md Schritt [4]). + Writes a *_origin_calibration.json report; never + modifies robot.json. + +Unobservable joints (confidence "none") are written as value=null in the +output JSON — never a fabricated 0 (see movements..observable). + Both the engine (estimate_pose) and a CLI (main) live here. """ from __future__ import annotations @@ -109,6 +129,22 @@ def load_observations(path: str, use_normals: bool, min_cams: int = 2) -> Dict[i return out +def load_seed_state(path: str) -> Dict[str, float]: + """ + Load a partial/full joint state to use as an optimisation seed (--from-state). + + Accepts either a flat {variable: value} dict, or the + {"accumulated_state": {...}, ...} wrapper written by 4b_revolute_angle.py — + same unwrap rule as server/homingOrchestrator.js + (`stateData.accumulated_state ?? stateData`), so 4b's output files can be + passed in directly. Unknown keys are ignored; missing STATE_KEYS are simply + absent from the returned dict (caller defaults them, e.g. to 0.0). + """ + data = json.load(open(path, "r", encoding="utf-8")) + raw = data.get("accumulated_state", data) if isinstance(data, dict) else {} + return {k: float(v) for k, v in raw.items() if k in STATE_KEYS and v is not None} + + # ================================================================== # Kinematic chain analysis # ================================================================== @@ -270,9 +306,23 @@ def _multistart_values(vtype: str) -> List[float]: def estimate_sequential_fk(fk: RobotFK, obs: Dict[int, Dict[str, Any]], chain: Dict[str, Any], - cfg: Dict[str, Any]) -> Dict[str, float]: - """Estimate block by block along the chain, freezing already-solved variables.""" + cfg: Dict[str, Any], seed: Optional[Dict[str, float]] = None + ) -> Dict[str, float]: + """ + Estimate block by block along the chain, freezing already-solved variables. + + seed: optional partial/full state (e.g. from 4b_revolute_angle.py) to trust + as a starting point. A block is SKIPPED entirely (seed used as-is, no + re-fit) only if ALL of its variables are present in seed. Blocks with any + missing variable are still fit normally — including their own multi-start + — but using the seeded values of EARLIER blocks as fixed context instead + of 0. This keeps the local-minimum protection for whatever the seed does + NOT cover (see doc/Homing_5_Pose.md "Wichtige Einschraenkung"), while not + re-perturbing values the caller already trusts. + """ state = {k: 0.0 for k in STATE_KEYS} + if seed: + state.update({k: v for k, v in seed.items() if k in STATE_KEYS}) var_type = chain["var_type"] for block in chain["blocks"]: @@ -280,8 +330,10 @@ def estimate_sequential_fk(fk: RobotFK, obs: Dict[int, Dict[str, Any]], chain: D bmarkers = [m for m in block["markers"] if m in obs] if not bvars: continue + if seed and all(v in seed for v in bvars): + continue # fully seeded — trust it, don't re-fit if not bmarkers: - # unobservable block: leave at 0, flag later + # unobservable block: leave at seed/0, flag later continue if not HAVE_SCIPY: @@ -458,7 +510,140 @@ def observability(chain: Dict[str, Any], obs: Dict[int, Dict[str, Any]]) -> Dict return info -def estimate_pose(fk: RobotFK, obs: Dict[int, Dict[str, Any]], cfg: Dict[str, Any]) -> Dict[str, Any]: +# ================================================================== +# Mode: joint-origin calibration (--calibrate-origin) +# ================================================================== + +def estimate_origin_calibration(fk: RobotFK, obs: Dict[int, Dict[str, Any]], + link_name: str, cfg: Dict[str, Any], + seed: Optional[Dict[str, float]] = None, + free_axes: Tuple[int, ...] = (1, 2)) -> Dict[str, Any]: + """ + Fit `link_name`'s OWN joint variable together with its + `jointToParent.origin` components (default: indices 1,2 = Y,Z) from that + link's own markers, in a single robust least-squares solve. All other + joint variables are held fixed at `seed` (or 0) — this assumes the rest of + the chain (in particular a slider `x` seed, if relevant) is already + trustworthy, same precondition as the existing geometric method. + + Complements doc/Kalibrierung.md Schritt [4] ("Arm1 Y-Rotationsachse"), + which fits the axis from a dedicated 3-pose capture using marker *centres* + only (circle fit). This fits from a single capture's marker corner poses + (position + measured normal, same residual as estimate_pose), reusing + whatever Homing run data is already on hand instead of a separate capture + session — useful for ANY revolute/linear joint's origin, not just Arm1/y. + + Never writes robot.json. `fk.links[link_name]["jointToParent"]["origin"]` + is mutated transiently during the solve (RobotFK.compute() re-reads it + fresh on every call — see robot_fk.py) and always restored before + returning, success or not. + + Returns a report dict; result["status"] is one of: + "ok" | "scipy_missing" | "insufficient_markers" | "unknown_link" | "failed" + """ + if link_name not in fk.links: + return {"link": link_name, "status": "unknown_link"} + + chain = analyze_chain(fk) + link_var = next((v for v, links in chain["var_links"].items() if link_name in links), None) + if link_var is None: + return {"link": link_name, "status": "unknown_link", + "detail": "link has no movable jointToParent"} + + own_markers = [m for m in chain["link_markers"].get(link_name, []) if m in obs] + joint = fk.links[link_name].get("jointToParent", {}) or {} + origin = joint.get("origin", [0.0, 0.0, 0.0]) + if not isinstance(origin, list): + origin = list(origin) + joint["origin"] = origin + origin_before = list(origin) + var_type = chain["var_type"].get(link_var, "linear") + + result: Dict[str, Any] = { + "link": link_name, "joint_variable": link_var, + "joint_unit": "mm" if var_type == "linear" else "deg", + "origin_before_mm": origin_before, "free_axes": list(free_axes), + "n_markers": len(own_markers), "status": "skipped", + } + if not HAVE_SCIPY: + result["status"] = "scipy_missing" + return result + if len(own_markers) < 2: + result["status"] = "insufficient_markers" + return result + + base = {k: 0.0 for k in STATE_KEYS} + if seed: + base.update({k: v for k, v in seed.items() if k in STATE_KEYS}) + + def fun(vec): + st = dict(base) + st[link_var] = vec[0] + for i, ax in enumerate(free_axes): + origin[ax] = vec[1 + i] + return residual_vector(st, fk, obs, own_markers, cfg) + + starts = [base.get(link_var, 0.0)] if var_type != "revolute" else _multistart_values("revolute") + best, best_cost = None, float("inf") + try: + for a0 in starts: + vec0 = np.array([a0] + [origin_before[ax] for ax in free_axes], dtype=float) + try: + sol = least_squares(fun, vec0, loss=cfg.get("robust_loss", "huber"), + f_scale=float(cfg.get("huber_delta_mm", 8.0)), + max_nfev=int(cfg.get("max_iterations", 200)) * 3) + if sol.cost < best_cost: + best_cost, best = sol.cost, sol.x + except Exception: + continue + finally: + for ax in free_axes: + origin[ax] = origin_before[ax] # always restore — report-only tool + + if best is None: + result["status"] = "failed" + return result + + fitted_joint = float(best[0]) + if var_type == "revolute": + fitted_joint = (fitted_joint + 180.0) % 360.0 - 180.0 + fitted_origin = list(origin_before) + for i, ax in enumerate(free_axes): + fitted_origin[ax] = float(best[1 + i]) + + final_state = dict(base) + final_state[link_var] = fitted_joint + for i, ax in enumerate(free_axes): + origin[ax] = fitted_origin[ax] + final_res = residual_vector(final_state, fk, obs, own_markers, cfg) + for ax in free_axes: + origin[ax] = origin_before[ax] # restore again after the check above + + result.update({ + "status": "ok", + "joint_value": fitted_joint, + "origin_after_mm": fitted_origin, + "origin_delta_mm": [round(b - a, 4) for a, b in zip(origin_before, fitted_origin)], + "residual_rms": float(np.sqrt(np.mean(final_res ** 2))) if final_res.size else 0.0, + "note": "robot.json NOT modified — apply via Kalibrierung-Tab " + "\"Joint-Origin Y/Z übernehmen\" (editRobot.js) if this looks good.", + }) + return result + + +def estimate_pose(fk: RobotFK, obs: Dict[int, Dict[str, Any]], cfg: Dict[str, Any], + seed: Optional[Dict[str, float]] = None) -> Dict[str, Any]: + """ + seed: optional partial/full joint state (e.g. from load_seed_state(), the + 4b_revolute_angle.py chain) to trust as a starting point for global_ba/ + hybrid. Passed through to estimate_sequential_fk(), which skips re-fitting + any block that is FULLY covered by seed and otherwise still applies its + normal per-block multi-start — so variables the seed does NOT cover keep + the existing local-minimum protection (see doc/Homing_5_Pose.md "Wichtige + Einschraenkung") instead of silently defaulting to an unprotected 0. + sequential_vector ignores seed (no x0 input; left untouched on purpose — + it is the cheap analytic method, not the one this seeding targets). + """ chain = analyze_chain(fk) var_names = chain["ordered_vars"] method = str(cfg.get("method", "hybrid")).lower() @@ -467,12 +652,9 @@ def estimate_pose(fk: RobotFK, obs: Dict[int, Dict[str, Any]], cfg: Dict[str, An if method == "sequential_vector": state = estimate_sequential_vector(fk, obs, chain, cfg) elif method == "sequential_fk": - state = estimate_sequential_fk(fk, obs, chain, cfg) - elif method == "global_ba": - init = estimate_sequential_fk(fk, obs, chain, cfg) # cheap robust init - state = estimate_global_ba(fk, obs, var_names, init, cfg) - else: # hybrid (default) - init = estimate_sequential_fk(fk, obs, chain, cfg) + state = estimate_sequential_fk(fk, obs, chain, cfg, seed=seed) + else: # global_ba / hybrid (default) — both use the same init->refine path + init = estimate_sequential_fk(fk, obs, chain, cfg, seed=seed) state = estimate_global_ba(fk, obs, var_names, init, cfg) # final residual stats over all observed markers @@ -493,6 +675,14 @@ def main() -> None: ap.add_argument("-robot", "--robot", required=True) ap.add_argument("-out", "--out", default=None) ap.add_argument("--method", default=None, help="override robot.json method") + ap.add_argument("--from-state", default=None, metavar="JSON", + help="Seed/init state (flat {var:value} or {accumulated_state:{...}} as " + "written by 4b_revolute_angle.py). Used as x0 for global_ba/hybrid " + "instead of the internal cold start. See doc/Homing_5_Pose.md.") + ap.add_argument("--calibrate-origin", default=None, metavar="LINK", + help="Instead of estimating the full pose, fit LINK's own joint value " + "together with its jointToParent.origin Y/Z from LINK's own markers. " + "Writes a *_origin_calibration.json report; never modifies robot.json.") args = ap.parse_args() robot_data = json.load(open(args.robot, "r", encoding="utf-8")) @@ -503,9 +693,31 @@ def main() -> None: fk = RobotFK(robot_data) obs = load_observations(args.markers, cfg.get("use_normals", True), int(cfg.get("min_cameras_per_marker", 2))) - print(f"[INFO] method={cfg['method']} | observed markers={len(obs)} | use_normals={cfg.get('use_normals')}") + seed = load_seed_state(args.from_state) if args.from_state else None + print(f"[INFO] method={cfg['method']} | observed markers={len(obs)} | use_normals={cfg.get('use_normals')}" + + (f" | seed={seed}" if seed else "")) - result = estimate_pose(fk, obs, cfg) + # ── Mode: joint-origin calibration ────────────────────────────────────── + if args.calibrate_origin: + calib = estimate_origin_calibration(fk, obs, args.calibrate_origin, cfg, seed=seed) + print(f"\nOrigin calibration for link={calib['link']} status={calib['status']}") + if calib["status"] == "ok": + unit = calib["joint_unit"] + print(f" joint {calib['joint_variable']}: {calib['joint_value']:.2f} {unit}") + print(f" origin before: {calib['origin_before_mm']}") + print(f" origin after: {calib['origin_after_mm']} (delta {calib['origin_delta_mm']} mm)") + print(f" residual RMS over {calib['n_markers']} markers: {calib['residual_rms']:.3f}") + print(f" {calib['note']}") + else: + print(f" (no fit — {calib.get('detail', calib['status'])}, n_markers={calib.get('n_markers', 0)})") + out_path = args.out or os.path.join( + os.path.dirname(args.markers), f"{args.calibrate_origin}_origin_calibration.json") + json.dump(calib, open(out_path, "w", encoding="utf-8"), indent=2) + print(f"[INFO] wrote {out_path}") + return + + # ── Mode: full pose estimation (default) ──────────────────────────────── + result = estimate_pose(fk, obs, cfg, seed=seed) st = result["state"] print("\nEstimated joint values:") @@ -513,20 +725,28 @@ def main() -> None: ob = result["observability"].get(v, {}) unit = "mm" if v in ("x", "e") else "deg" conf = ob.get("confidence", "?") - tag = "" if ob.get("observable", False) else " [UNOBSERVABLE -> 0]" + tag = "" if ob.get("observable", False) else " [UNOBSERVABLE -> null]" print(f" {v}: {st.get(v, 0.0):8.2f} {unit} (markers={ob.get('n_markers','?')}, conf={conf}){tag}") print(f"\n[INFO] residual RMS over {result['num_markers']} markers: {result['residual_rms']:.3f}") + movements = {} + for v in ["x", "y", "z", "a", "b", "c", "e"]: + ob = result["observability"].get(v, {}) + observable = ob.get("observable", False) + movements[v] = { + # Unobservable -> null, never a fabricated 0 (see module docstring). + "value": st.get(v, 0.0) if observable else None, + "unit": "mm" if v in ("x", "e") else "deg", + "observable": observable, + "confidence": ob.get("confidence", "none"), + "n_markers": ob.get("n_markers", 0), + } out = { "schema_version": "1.0", "created_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "method": result["method"], - "movements": {v: {"value": st.get(v, 0.0), - "unit": "mm" if v in ("x", "e") else "deg", - "observable": result["observability"].get(v, {}).get("observable", False), - "confidence": result["observability"].get(v, {}).get("confidence", "none"), - "n_markers": result["observability"].get(v, {}).get("n_markers", 0)} - for v in ["x", "y", "z", "a", "b", "c", "e"]}, + "seeded": seed is not None, + "movements": movements, "residual_rms": result["residual_rms"], "num_markers": result["num_markers"], } diff --git a/scripts/__pycache__/5_pose_estimation.cpython-311.pyc b/scripts/__pycache__/5_pose_estimation.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4831c796ce8db7f2d9a1426f5ea0d65206065ce6 GIT binary patch literal 45870 zcmcJ&33MFieJ9u_`bKx-zDN`SfDKUCUN@{A(RJ&z^sFD&ck9`&q1(WIjon7}ThLv=eofsb_G|7okjm-tLqN;3tEmt~%mzr0qb@s;D> z?yGoDtI>RbfBCvnnR!zCzlVB#fPeX7d6|Z*Myk83M{2rjv>FT5&^)|sWO?^;e6@7f z`W^KeAGcB?81ZiHu05~u8?I|^=+A4eYk3=Q=PkU0FXWxPi+2xb_@Z~TLmIyLfwg-D zU*fCdOLrhE{sdp~nxVU%KgqwqbFUe@SMsO$I`}m3r}+l_uHw(|P55o(&+@JKZQ@_# z*W=g2zr=6E?`r-WzXiW*P`>kgH@^dEH1ikuo$zbnzrgRsZ!6#Vnx?yrzsPstZLNjs za*6NZ4wO#e<5yW*2zIpL`u9-M57I58(V({a)9l~VJNDl# z8jW={5cKu@gt#k#A%BST`Ui%6L9XdWGk3F@ z8*k>k&0KFY*VoMXJY2sJ7~w*~5CV-2dqX~+8}SNPeFDoc*vyRuhx`LvXwb)99rF7q z^FD4Q!25=|A-|kU>y==@&ox~e^2+Z${Z~;sPpj3s&lemT@LOBhWj%v5(iI2@+)$7k z^$Ok*AMy?&*T6NOz#Tqu==d4##g0>lI`$szI^E3qQHkLp|J7hCcV-Y#2EBfs>VooE z5wY*;O|H)ygh!wseh47fyWcx9G<=gA@cDfRV-&l;4oRUGt)ydWU@|Jd)$MKyT0|Tw^`~ zKk^F=3W2eKL5}wY`+R=hi?){=GuXj(1^mH~FxD4hjWv?) zXLLb7%K#;^?hlL!ToA3%H^}OMz8DPfXbIMca<{hZ;_^qH9Lz&48uYz9=JSV!yu&@$ ze0?Ew>o$xw@9<3;C8Ow2c>uC8CbvY(s22^{hK?0RP*gbpx_o?b6YP)-zs zBHJ55xtzn89jNRO%`a|ErijRBh#&I~V=lC$UhX~ z(1N-A1s|H%{m0|Hy4xF+xD=qXx=UOI#3(7#pBmD8Q}7q(-HL?D~z~Ko1NI`1sFBP9AE*H(R-vu;>QH zFdY=VjBUs_=)E>X>ri@0UA)`Kcg&ATj4ws22m# z*Afth2CzEppw%BkDmd!v!-~arv2AGLY>Q`h*YujwKQ{b%4UggPYsQKiN+;fk*?=uY zUiH|(I4*N!sBHInHF$UPii>(}M&`=B2@vJ^rPpgrnH`5%wiSh4xc`8 zoIwJtNGN>27ps3C1DMuwO|-C$0EA&p+`0p^Xjf~l00ax|4FKq%IqmafF#5Qr?4+f} zM?q+Yah*pGaZMxG%RK1iYd&t!C-_>eKPR}*qfOboegH&lZ4Dl$_w*d)hx$TzHJtQ@ z20g}K@M zX|buL3S^KWRkU!wN!hSq_Q=abFy)XRdanD11_nbZo4n!n(1x2TWO?-f@(g)NPC$NYb&mbzoEWScBxq|f10 zSXvEBrsa6PwD`ht^d?f&PXBA(IbjKFh09B}s7u|iUplg6?yl#{eZm@I)E&95SIn6@ z#}z%F@zZF+R(1Xpwy=$NUo}t&!5FrNZR2QJ)WICKELn#lzF6IwVH02Szz{Zs3w9u# zsCIgH*nkD0bo^&*A3chRn?;UtxCZd+j=nx0Cj8P^1VfuMNCZnvUabjL8XL(hy&eL@ zWlRJM5W}Ui2m_%DGIqsq20~{!?i5>3gWEF52v92EGOr*gR{3pB>zh491|V#ay@Zcr z<8Ed!&^ESQvqe9+4Qs&!OE0ArJ~NJm`dhZVuT7b;*#kcC52Q?KU?^otZ$d+SN;ky+ z{D0ySXwggA6t(zyAM?qS7bQVrMm}_N)R!X42&+ZUh`bZ-d413ERDoQ^fj9pBs=u+d zx_6}vw7q<$-Hmm(K(36?Nj7> z*YNKT+Tl&DpSgrJ6wb}obp@Uo6wbllhyUPBEHIOrzqf1buIa6BoQbWB>t_ZMrJKak zO%hz&X34gBa$mBc5o^A~{rb7v=b}ULBGIu@a;%)!6dNyVf5F^{cCOGJU2(tRZbQr# zf8m>F-#I(8VYcY)o_Bl1qK#6~MzL^{RJbXk$DR;%-`*VAoV1tBxl5ui+&_EwY;5OD zomko;m9~iPR>|EODM;Gf(SuKIH3?e{0w!%0v5tgo8Sc5Fl6y_D_Si+Kdd>GYCW^L- zMO&q!t&z?M3RoF?A>mk#I}_>)4|adQAyKqNEZQO!ZF#gwEb5?;A0x@aA}UPTyvATz z9&7*l#;9V!R|!$ra5=R{ zm)!M{y-BA#y6^VQ$jxL~MQq)jfv7&Ye8tS^M=SnK!|yjdE_k9Bw;h$X9Zj6Olql}O z9nnWRr_57kwlxYZ=z`B&xxJ1Jhl?sG#DpN&vb4Ya7!Ut!o1Hd9>87u+Hk4h|0MB6B zYkR+@W#@94qT|B#G0a2-F56_@5G;>5}0D%Ey5u}+xJ3s?mjFdpw1}jPsFfFiOipB|J zAo!soM0fNFZG(*Fazf}E1U3OcI3NIjVv(9Iv%2pC$lAJqw_IO+`&S%Z+jA&?eBgwUV$U-c8Rd)0T7HVqIf2Cyjk`0b!e zGD-uN!z|eM;1G~dnW*B$&I978OnU}SB*WCmftnNODu{Dvpfq+P6EH!nZXIZ4)QHx# zX&h77h!G-eB4;z4lzTxwuj-~(oYuUrJq`$(7F6LFocHx9UF+JE_G(IdEl80un%L_4nRN$M zk8tr_li9e}H*v`uLoBLuNv6pAte`KBAJ-hqy z%a11_9bXxXzA$xT`o`l4`eRFl$0Qs?MBxxQRNa(y2-F{RI*?zXk$h>JNg07ajRZY< zc_TSVUV6cMT~+RsGq0>dKf=9+{~$eJPbho#o-o(G{n5S;SBmWir1k>|>xsz9*PCuP zC5l(g7({D}WNk@UTb`W4w!tbH@BHSWcMiRM)heayzbX*4sp?i* zk=VMo4IO+Roq7cx@&z1AlLPc8bXaxtOJQB_A&PRvx%d(r8ionufCk|7QUJR)AKV{> z&pyq?brU9D7d8PvZszsuw<%l@H)Kkt{8fUT0N->uU{gKZ)NfkN^!ji?SRXbJpf`OE zbu&MA-J+SgEm<$aVz}mnCFIDJJZxDCiQx;>>%F?`Dit& z*3gCxiWC%!RIZ%-f#wjaY-Qsxh}q%@>rmS@K#~n%Gy1UsD2$@CVaGMCP#tzG zQEye){5F_wM*4;59VdQWET6wj=c5IpYf|Tgeq91h4=+H|C)_}}oG9(;uv=&h7lxh6 zKFYNRRM;h~U$XQrF!F7SmeKX=moe-J+d{bx(}eBnI$`}RvdfErJ@ikzX3I=ixSlNFBP{?RtI55Dt$^Z&*c+g~JQZmb&qwtJ4Nold!uo|SW<9SJPDWQ(`5DXh&_5rG*=|d6(85Gm^_$Qh80wX73nhA z3t!)Vdw+B{vbQ%%_Qr%A*@Hl0D~!DS`q=HU=!>yaqLY)HoM>Ai*;agHYkX>J6m1^K z=1H`4CTw)ixr(D5_q*=&tClxOe7eV$~Mh$&$+ZNADhuyYHO1cOrV?)1#W#Nc z%%l3>Isd)$PwEm?$8d}G6Wesy89dp&oW7po?{$Trz`_0{vLs9S4(a2G>d}Twj zq-L(PGUmC{bFU{^u`*dyF>fod7tCv9$5f{E}gZi4XW@*&H&n(2ky?~~=D6B?epILLw)Tbo3 z0r{01>1${@HTf?LG+2$0$w;k=wqaNtiGf6>P8~O|kxeQB;ZmvSdBZbPF^Ba4?*2Wv z9pH6b(*_Or)d8&3PJ_!7w&Wl<@K`LUY5Ka7ZGo_rH|9dA*sf@g9GNn#Q>MljQrXWaRKHdYmU|RN0 z77r!BYyiM+R%o+TsKP*?S&3`c9>xhdlQOgE3Z9LyAKr}Xay(_1`7`oV7WUwS$IPZF zBihMKm$el92%MD38)OTSFhOqxp#XtM9*014UtR^iN>BI5p;?(SviT3pg&K zm|TN!nm+0vuAt*b@$U~3{X_-h$iR&=Q`NK}rSMw__jmXYuEz=n8e4(JQ5CC;`9%9l z$-WX`PI={I=bY6U)!#SWHN~7WC8DcUaPnbym)6%tmZVaAZDSJEEHdSk)cV#kM^t{I*ea zv`UWF+2YyL->Z1kB(-;nj!tZ?W*b}H*4^KFck7+)_qJytgQN{Wht9GxdOB8o?_6w9 zDs4=Zu76aBO>N6j+{ArZ9Xk~-l$N!9zf>&REER2jRR5^&hfR;SfEgkd9Zj4(ExJzw z5VJd9KYaUeRJech?#(-|+~>7gW3 zlzWJ}PNPZqHUdC?UK8Ad2Z(2w?U6H6)@kcx$6RRzVBb!$v`H#$!WM5_#+)fj#1QG5 zD=Lra@2tMJI--rNoH9jBfQlv$%I&JVu~xPD(5Plj_dl@M-qPIC-qPJN+_K%WqZ#oj za|M?xn~l%guR-C;F6Y>z^Jo;OHOh@Z6yWqVcupqVT{<&bZLRAs0B6iRQ7~m# zoW8^_5CxeqDJecn#xwI4^)OYp&xF~cQPF=u^@hlYSxG-xGHokwdsf<^>RfB__RzB2 z+f?4vQl~;&*F(p9a6ib74N#$LmGQ%tE3VA@lua$Vb6Qic3Dsr3YBW$KSpyIPV>NT} zg<#J5MX|Uhx+KgI?$9a5=HlQeqp>Jad99#kPF13|P05^`2?H)xcDny39GA;)Q^m62q}cn#Q31;l>( zS*9xQYu?uhJ$Mr?!%2&Bu|+}Xg~#}+46URmD|oyZ->D3B{UvRz#CK|x@94{sjz}>3 z%2okYIcXJQk*K27P>${N9BVBt96&O|nbg3H1GA{_)ypbvgo6QHu}^gBF={ z)Wpuu=x5G|j&+h_9X5X_!;9m=nZu9tqH~Aj+>tQvkQ;?&v_KP2s3C`#B?8TBfmkJi z00*p42)00W)70O?Kst!=L1@$8@4Gf$nVuinkqC)eu$}pBoTn&dJ=pPLSI_CrLnqIt zjH4jRg=C&(7>T9~#BrAStxc2@0c-;Gg94JH{YaLf(IZFUReEJjdK*tEOV#Wh8Lb7sEZo>eK?=8Yf|%xt(0gU6?0}sqVTY2J|dZqB+N(VKziCseBFq1&RrVq zyFYk$@XnQcS7hqU>1`SOvd?n@_LJ282J0HZD0p^OI*9ZI%} zC0nJEtseqfIw0B(NVWr$`{oTg<4!8W^E+k@0J6=uH%E8I0d%dFY^x{t%@r0$JMSO3 zdmvHMJkuwZtdmMWQdut*uAe+GuPZQC%@tP1I^!iX`yMsUH8jl>f2aIg<%#x=$LmGU z0m*YfY&a-29Q=#*PhS4%sk4a}d&E$T24ND-0yE9OS@pJ0IA zoT-1IYw3Bj5pK$Oji|Jr*%-RMhyN2V!2*LpF`!0THeBwI3C;f0~&(E00WPirT zpMH2O>|rs4-$ekdpnr;=AjX*{O+YQ`VbcDfzz89*DxfwK6#fFATWCOXc$n0DY_dnK zPfb+`Q&kdeDc|Nz0KOv|Cy%mupE5G~Ysx^v3!hbGM9VMa%O+h^0f+=FF#z$mv7s>l zQGd&DA8i1D44*PrKF~B_oH7XQVPl-CvEU+b4gd+Y?1D=T2v(+*F9*9{ z$150?OoI21Cm{c-q%lmGBU%7^1+SUmufVzU^CAh(pmw?rHK|d8un_}dn-SZbA46R_ z&_MQtEl8nLDSU-@mAq;=53>O4ljKIN{dx$JX;4xf8w68qk8N zU9dP+AZ%93_44_$ypFJgccjO42N;BQB@>-@sPh7r)HYH0JSCcN5<`}^?La?=?Mukh zI@PIz(YTf`3}v;LA&8b2V!{m;vK^&(I8l@b`l^_QS7?Q2SGXuda9H|!=u!hPQ~Jf* zjCb=z>X6D(Eg7qrFHy%**N-n%g3)~0vwBsX4g;#o)u0ElqOW98ny~vStD{h;@-5B* z+7qq`_w-u}fNQ;0b6zw3eg+s0yS~&EYRI$~`pz8AYqKn-TryFrY?W}Sy0$CBCz~xG*t>tHvH8 zGsc(f%IH@!Mi!~>$`5&(sL%-$e7n4pJ5;-Kgo46*b`$Whk#qs&K1{~My+=~6;J`t*@QCr@_m zgBDTi7v6PSrAO9Y07AgW2~b24!b84-ds>V#BWY({;}i` zd~I6A{Ho6L26iOlf8&_u`*_>W#VSclL`i4n(jEV{Z$i zcv`$tHTaT6h|Pq$Pw60ES`gL(;kBeIA=f00?w+Dl2_;dXyTT9PGyeJ2$>O?)sXEIM z=~L9Fc!zw)R?N0P($8+0-4g8}cXo?hldTA+DnGU4dLle&0vhUew&z zVvn`(nl#!f!<|Z${eMSPRUJOF>>!z%8>7OmanI+Ds!Y&hRFOuc_37Ve{)RfIyd8Jk z8`H+l#4Pfg(v?RTjr+`H!b34thT-daHNH;lF;E*(VFkW!P|}FPgsnsQY0CJXiV~Hn z`0pY445D7pBg-@BdwNG8i9u9m;rH=ffw;4-mC<#KRUqv0a3xf8y#DiA1t|MDYL5)c z?&jiUGsQDL(Y--(Z-DASab0{}T$ouutDikB7H^e`wkQ^@s)Ljb& zEAVg`i2h}CTmB^@{bh@HpYXScxq-Uv{{bjLxFsZ3mTMdytcP!P!x8OGlD!GM8Iz4j z@Q1WXv*UI9ZM(b(#Y5j5e`oyd@VjBWi=_u7xaNbB`C!6)FzGCbc0)pB^5DnrvgnO_ z5OP^#^!$RHh%xCZjrQMj;2|^ru?ob-Ai_}Fh9|Ljhg7^HVn!Os)l$lY7@X|!jZWo^ zn%RgqvAkV^ThcC-v}c1m%YkKEN}(t739*&rL_Pst-8J>f^eYj=$2BYBH)cZ=XxsBR zR!vEHwBfD#nD5Qi4_1psD--SpDEXAsFe!=B_{;BB%xn=$Hb^BKB9^4BBxZdI?zycl zSy~?1KUdZ?Qze#dkjgedvcUln7C2E|va}-F^;T!x_~zjUhsBar5X302jB2AR?ie80 zao=&*5%Y>JPI7Sxc7I&8{2Ru&?``|L_Q+7gK(Y)Piv_iV6WKejGdn8h%If2t-!6Js zF>UM&Pm* zX&Gdpvu zr|KzV873>1#k>#9@r`0d(@YnJ$Sj<#k*?^9se_+_{Wp0uh=o`4p>1#Ffd<2$G*}O; zGyO@s7H*$%fwmwF7GUi!zl-^C3*_U^r3@H=qbo$^CydX=;eowF;V1|71Mm2+5SWMX zc+L-u1={t}SJq`X?nl{L}Pye4vwj0RoT40n$ z;-bC>?Ck^m%NO91dGr$Pu?w#E;6rP>d|@2OIHDB-;BG$COC^+Jt}mfijNrVJ;fRF{ zN6eInWBRo&@2gnd87|QQNd{&Bi5Z2FF}Jh~Y})R|O@R227basvBz)>?<@N)k$jFPz z60S1-#1h=Kb6r|FmW=H%*qAuh z6bWU7ECnQ)W%QLHrvk%kkTPUdVWCMWyW0jKSu!G)k>zDJx0u@4P#WzVzV3w@n9Ro_ z6qG69r4d-*%s}&?qXzRjgfBs_O#Ym+9tC4by}mv#D#Xfn-8Vc8qa>qgi)2j1R#q}g zqtU=M`@mTM)&*Q_BrTt-0$*teF^I1O4~y~5J{Rk0U2H2)H|Qdm&lLJ!_&<=D0y7|a zSy>L2L|_??Aj48vf@MCqdq557kq4V>v4cr5dx{nX1E(o7hJxi9`me~3AVFCYc@j@40~Dcy zDKi_6J=fNP3q=O(=*g-mlpga{_%8?_B$$`Gyf}bYwe+YE{sHkOsU`ncI0WGpEe7Fb z;k9IOMQpiL+yv2AfN57H7bI7+FkG>54`gr2RX}kh7(6z$ZF*bGeS3FgckIU3!(R)} zbj_}Kcu=g}DAjI!RQ0sBGf~?~33~6DA+qbPinZUXiFxnULWYKQYr1p8AJw)!t!ff6+H5{NP$bCl5&{~H4u=|c} zuDWTaXy%+)y-BLx1pj5VA1zz^blKY3qSak;zlZagGt0b4^J}vE4!+v)&mfX!>}Ldzzz(NoL_1+niw0cD?yh0Il0KieFT$=z zNRfI@ZgB~5lRRoZuc!To*@J*OBIxR-i2|JldlZzb)j2U<#fOY|VrI4@v}tKd{DPC< zREpRr%C1#OMJnK=rR%h0N(vZA32`l*5|g~N=E+Ga;4*wsrm6VCVnX#yRTe__*#*@k zqq=p%MxuSQSav7|DIk?Axmb`mT{4#A*XqT5Xv-v1)r0lFRw{X16TqRYcIeIjrsS?% zAsCunN*Ha4IvQ2})=W$&CgwqD>XuN$w9k>x!t}5-et>`ZQl}2+u^?{Cd{h4BtD=Ep zXIebuuhp`7{qt+Z&Fd8!SL)W$lJ%882^R!)L?KfOQGx5HQ3^x>1ISaD&4=REDNi_6 zsagJsskv06s4&y+D&Oqbg8E+`2S;5t@Jp=68I1|%#O#pG0ohu%ST^APFD+ks^Zv^_ z1qMKn?hX^s{W;A-6&yq!>j@b=jE5}Yak+vboE<$2QG-wy6{F&bwuTWws(n<6pK3g#V5#VDBps6#gfk{%Q681C(3PlGB2;JkFGb ztg?k*wJRum1|ONu_ceMtM$U0^PQXd&`9Mf6iV=n={OcfEAOIy|bVBsc*a)Kmrc696 ziu!y(k6XxX;9sRwXcuRBq_p0Y0q`3X(yl|5Y)JT5l)mm7ff&Oz8g{igk#N<&tB0yi|0o63vbH2qQ4-B6X0|an(fau`}^@(bXWi8lX31-2Dr3 zrVJpkw37nTL0GiG9oNoOB)E3mpw7v{*ygzVfh~SmsvwzZ$7ac~`A2m>T>bdm6Yozh zBwn~6b$#Kdm-@tQytIuM9X#Z-9j>T*`o-u$$+0Z1k9WLV5WgXDYemOed~>?r*n4k( zY+dZ-JIC%Fi+6}64WhFFRIjBzkW|su*LU6C6MSodZkHOjKk`0p+?{CLP30~un!fT@>082sYjH0lo2`6g5Is93&rY#+msGn;tlTYC z?*6d!L*Xaap74n?=fnf&r32^1j&7-=TP(aF6<&a(y4n7^?Y1r23H3UOY1olsjperG zjkl6d<0WFzDye7{a;W^y@^3BA=25g$H1CqkyAtMIcu3la#%U~pa$h7gXST_+ z@!R^Dy|ae*9S84rqMpGc&+jaC z0%M9+Sq5F&;WBv`!>z(w&RecLfoZmPfO4f!I=}5=R3ROxLMo}1{5|8gbIK*?rkv^h zCkv+%e_Op~-!6<4Mw}5B6G7HO{MbO^$Gj1eBD4;rucGnm%UD2--7tXUO)!UD>k36z}=CajR_AZA1Q3KuBp zM>(-=K#*A&DBrLVB#abomq`iKpamk&$}%k(%MK|RtR){134mSdvO{8pw#W3vtKjwg z9#E%4{Q-#-xC&8e*p$1Hty$QTh8zo3zP2k_m}>$!!}J44br7c`eL-Ye&!jqLvQiyp zNOcgiD1A-2!e(_@(Ds#VyS7g_l&u2jQq@zS-Pv~_mFWuFpy}V^KK{?bOI?aE(y)NS zpxDZ@^pHPBDSw%vhd8$+*d6uGygb`Eb7SU4R7>v64H>K4iVCRU7eXb_iP~Qt#6!Mx zu#y}*r6&@mn)3*$;DRaLltJB*X-*>y4gD6tLbTg9NHp- zR2E_};!1F+s$D2=H59oEe+#d$d8U-6PfRd0h9n@5z?L*>j@v zyyQHeFrSy3;k%S4jcUeMT)aW{;d7R-RYSb+0f>QPok50~D_-+@Oz8%S+_6hd|`JU~Ku8XdW)yGP|Q4iM8 z>UUSqy!>{nSlKQXwPyp#4e@_b2|k~f>M0Z+Q^-bga&)u5pjQSc(Ylq#Bo+Q6`Q0Yx zC*-^j2VhaQyM@0ZUq%vRDb4_M}CdgpO?_XWv) z0ktZtdaEnm@aEA6MikqH`Dw52C-p_)Uf4I z@ZVhj{p*h>5-(m9cVCirUz%%bnd$q^z_$hxoA$%_U+Yn+^{Ci%OlmroICtTvm%NGI zzMuLA6GMK{7m$1b@zSVtX;eHn3cg;kKvK|fAq5Q=RtlX((>G$Cr_TC>vp!kZ@NUyg z=fkephNuPXK_}DTrf#vw4&brKK_p`-MVEbSvL#IAbCor*v+;dmrAMkH&Y%SlDV(W8 zk$utnxhm9#xUt~st(-a%IWlk50QxeO&+9S)8ISMBZF?)a%(@TF)~-_Hhb3CLX+yo> z>n}9ao8160j)+l%O;-EMcQBTS{Ray_(B4*pJ=juyJfY8mK6<6V?g9Y8pj=RRwJe8T zNt9A=N9s)sCVNq)%$3g+11SmH=0w4>!te!Yrk|cSO_=;8%qTAwGRz)`{$fmF&eqUQT1%%78wfl_@|7}Ymb`sy|SF+$HP%)bHvPHVuNYQdDfZ?odLu9fGa}84swGm zwT!4@+O|v|EX+VDL?Lk~i$nG{_ci-6%&_!{CZ6pi+Ly^beVza_jzfnzWH@LfOqVM! z_(om`IhM%=4xO`BM?NXoAzR|h&%L%v3Q{1Ai&Sqj8 zvT31)U;yHbp3DI@!@hoag}AkHJSM`X+OcUkF%YH(57`|%EBm#W^x9Go*NTiG{Gs8Y z(9Ol5t^bO#B$SZTg4~!@9GOi@VBm0eJC8NJWM#ECLfeB4{dQfUH!2 zZL&=N0czfUv}rJ9WLD@&gbA`#UW5!1!7RIV%CbR@Oo>=S2u-N)U*kVWBp7U+^Cg-_ z+D|f4JWk`5c?}%!f1a^@MhwjU%Ns%Lt7Wi6Q|^(<)AnB?M*6hxj^&;u)&;X4cnAL| zG_`elYfO6^)=iSdl@V}zNY%UV_7@^wSomH|-#;#{Kp+(B+CAt8>n$i292f(E3Qx(~ z=ifa)>wf3rhsBT2{9)x!D#^2S=Gwz$V#8*sVe{iqvaxyQ+`C_dE=@Ti!oil}o`P|b zc|9csJtEiS5!Ufgdn0&`kq05GMJA4zma~O~slh&k61S6`UqIF{m@(CheFcb+xw1I* zerzXcC1m|Ar6}KvHRo$w-54F50g?UJDM2u6wt$E!8ENaRHaw?COi)EfI+j3S= z(Pc`$SiBWlB8y{*?;#3-Sn`#>QSo`4hT9cW6_Wt}Q6?z)K7dX*+we$Bq<~l0f{3%_ zK9g$ZcS`qp(jd31mGz%e$j>aEB5z&P3?wvRmIx1d6yE+$o;LjLN+k|BRT(1QTKZCN zxxo@8wmwy<-kNS#PE|tJhG~I3Gh7u~OR7!zEGX}1k|>tDj*Y7nBKN^ywk7c zT~k%6-14X=N{Yf%wYtQl`SHL6xguAlzL@VOz8EqP;E51sdAGDW@D$Yku-gn1@-J%KG05JVzqEJbp zxI$|KyTXM7+DH}V?=mRq8}r+ZFia)w_t**E;sfQiukyohlbzRAhx(U#B zroRtb(W*=wre3dSw4y)E(26V?YUPWL3HMZ0*qxK8!c2BEkh0KUTK~CWGO2~_lP11V zITMs^8n*LIxg6B(2Gg4~CK=Kp*uq7;hx(dd%_O=!j3Zp6n&tUpuQ=q%)f0l?*ttKz zzkH#FvL)$~33pzJ)`Uwar=`jT!=G|HtO%{iq^8kKm8;XAfML*Z=~M-^*yohP#prG* z2c?|LHQV0q5CK`~3u%G?HC3Ijh(1yFdA$bXzo5!2y!d96w3u(f`qY;DioVY~ry_sm zh0CsFX^z4sWgDvIakfG#->OVb;WEB-9J{}|^`7;H^}U*J<=Y+;`=}GEP>M0)B9rw5l$1O%P99n>h&+SR2g2kHMx6 z6C#B67uh3aMI)iY^ihl`5mRQ9WfD=iVVt}raF=gfW?Et>azDfchke&@N~VV}!L%Ne zoDShMu;LUN#F3256epC-09*6&=^;?%qMUpXC<~ENl;Suuc1l@Vx2_Y-n>qE2l(3$0 zXc^a3*CC9wbLU%5We(Vaol_jF=xJsJf=U+5oXVOsy@DaYQKz)sO!wz;rf)~bCSd3(PJ81nLl=!^Mess)dS1LxrtZq=C?AgX z_Yd_^?K3zs`!FUS@4yVsG7s52M;$9@%C7tjYhl%iVc%O zHZh3;!!)5&R{4DlmXA`dbg|`0K_>jxpw)z0a#;I0R705g$@GGf#YXbt&0K<_{i7tp zpR;wdr+n;2>k8ke)*z5;@xnCc3iJFbufk6$(MxdDqC36t8UJ-l*+FBV9x=h~D%mgJ zZ}PB(=(?%Hk;6HYPDdUUi_V>rb7#W5Q(n?W zs3L4O6AyFo0n6b_mLO~;`(9y%a0=M7Ht?!{ml7f;!aM5?nU^s&pC9n%hz#g zoy_u8e>lPJM|JUIvvtzSjq)p8xpI4`OpCE)vqr0aPP-M;b4?Y<+nvnM{u%hV% zTC>!1{Ri-Q-npJ_Fse_m2{AF(tGng~YuByT@vEVhZP zNA`GFdj{z35k|;dCX6?Ds+lc&W;q@{umO=XkuUU7Q-{fUm7K@q5Sdb-))M}LoMYr1 zA?GML4szC!^B>8%P0mlq*+m^j%Zevha9>x{Q**Bdk_A;<#26Wza?X`h#LDg*y?1o-SVq13Bj?Je&Xw_VGdD6~ z6DYI0YUW&W`KpySU;nDfubN7kPR32OL)Kr}?RVS<4l_5S}#O$1` zw0;mu>r6mXh9TnoH?O>NmDb%c4KYQir>OG0o zdm{YngSQ9K%Ee+~gH+g%aIC`p)BWcX=ljI{ytJQ(w1-+?V_xTg&3m;@_grx)^s7(B z_GR}BJUq6*5$g|2aBB|#6c|DAIkEVhRD3S7Z(e6gD}m3H%y$0Zz;_QkI`(9&UW9B1xxZpAl)nr9 z3CiE|Zqi#X(=ZkBj1~XA$;wr6L8@Gx0SQPJWX-2h20f%L{Vx#)Ht`BHX8U9~SzWIj z6rVtqtps}D<#%@7+Xa0#lQX*QscBilvO&*DwMMGs?VvUqoV_Iju6m~JB(p4z7FlCKc=UlaM+srB0vc3xYgRTRT z>i`apD1lIIKO~#Zqu*GAv4`3s1n+XCXX?(v_IL|cBD-6q-Afx;SIV!Td&2-pKY)n z(Hj43mk!T=uF>N8&$W7bE-O0Hq+eXqK8#gZ4X)#q0Ibdma2*GDDZ%wTff(K5TJSui zlApzWcpg3}_^rf1PhZe;%i}0QLmeLq~ zGHZ~wjiohsvsV~`QL~fs5vZX-z~&GRf(CsS%264`qH@U8Vj_ExN<5iSBQiBh4))N} zI`>TO2%zr%vqz7fm+gfSHSi{D3q~nqg!=14Q1X{G=n2Xm0_iiuRgjIQkx)^Ktj5pG zd$urRH!}S(pdjuO#hjX%*(u|4Hpv*$2r#!|~SYo;U^2Vvh#Cbs(e zy=2%8baFH(>;jDW(`MFC&b6{cn4)!M<@@wOvvde9LN$Txc4IA42W@sc$K;yE{Gq@Y z%nk5Fk{ulrh|*2%!O8_D4jP^6iV!$ammMk<8tUUl80$c$J=15RpfX&9vSB6k3gGPo zGlzi%F;fI$Rtq$fQtk!iOgR)@{a0b8jF~M0rYPgs9;0l+h^0ZoK*CWt@-gIT8%7ZA z{S69EoHSXh{uO#+f&YP?{+664El|nLt z1}hn19P&{ER3w~6-W$Beib!HlLm}ZMDxMzbRF<-lK>^b8P1zYiEiQNwe4HTtop`_r z7FFy7iwqD?8tQ&%K208)gLaa9Qbk34i|B5a+|8n+MRK%!?z`uHr~7-|;@UmZ+C7i!GlN)^^F-p0Kt*DW2^7xU?$X|0tBKsr|lww)1z) z-y_>ScBs0`j&U19!c&Ih7?OmhW-nT-^+a5M&6dBF=d>bu?ZZ6V_7il15;|t##p9Ts zhj}7NV1zCe8X`)(`JqLvzwnIkP?+I05ADw#-ob15!iUc14(~#E2;4j@R&V~#C{YPw zl=5YZ#z4z1ywqDUthy_}l$=t(3TV*gRLl8tzT#n}x<2add_t!J5%PNurx)kcyalWB z4N|*RJ5XSi5!L+o2BmlnViSdfT~Oj8dd7ka%<(+c9;MOQC1cg_%bo=Ss#^f2o3vMG zEVD}}QG*V-FY1;8of5n%(0x>AS)rD`ArR9t1>>DC$xd@+>$lO6Xe1P5e4A_ z!N{)&7tm=D$i0r}(CRW_q-n3v0?)3fR(PZo)&N*aHO+p9*`k7n_3x=cM3bsUD0H2L2pG|M&^&O0GDAU-C7+X{^w!&vy&WGRr zn}&rgrAlK5V(iStSghRseX1C{!lor=&2FTn=q2@BK-=zFGUgV(V?pS>3u}*YMEc9@ z8a)(h&OAdOo;GiG#j^r686WyxB5bbWionPvyF#e@Tse+WpH7^gf*s|9?5+ZlN9D>` zl}R*gQmu^^e&50skze+(OT9wm_4fX-WwG8N#&KwKCMO8z(R`EVm>L-bio_qtRTw9I zE6377bxpMz{t(2w;xLJBULi;{flDZyCu0ARePl%EJ zGp|0Lp5x;!T^SqkOhhRyAjoV1re(&`f?R|o1;OpJ@xEZ6FvQNIVY)Bl2fe~rUm(p# z?qT6%##@st{KdRrnO%Stbqa_OOfW9kvpN$F__a^)Aaw%-`bNhKT3BJmO)c`n8GOM3 z)i0012s}S-XbFsk#*HoXgAcH`!O-$?b4!bi6^~buq7&#KX?Z3&7x^2ce+CG#^bxve zZ)qX%HKIY_3{}J7(v(fzP>RKFi0Rv z=16UOrM0znVuA=YB=d#CsfjwW7;OjQb7#pGAVPu^00kW`N5qloH%jUQA@^WOf(MBc zC;I{rcVrZkRu1I^aip(JmVWEm*mDv+)H(tdBu;)s?NTPx*o$JzbuDUXfuz?^FG&rw z$ZWNg;pm~`N5=p3kUtoLfEWqKrEy|LKtRUK@FDdmjtD@5Q1G-Yr%dKz4YE**R31ec ziK4UsDM#E?CM~E)CQUhLtw7x&Cykf)(IQ2 zW(b?ep^a6hchI^m(0aloZ^jER96G-L#KklLW#@PaB+X<^Cv-`=I6KFk@P!m$k4&@* z?i@G6gGehog<(qS3p9L|Sx?A(VntC|f0B^ZP9{Etcr>`if;(m5AF|mlY^KO&E1r}GIoMz0yKux1t^KHVnrxdGcC zgGGGml=fyyJD!s)V773CGU^ASEn!MiDA^-UPaEJXtRSb0UYFx(|9Az*T{wIE#NN|g zr(W#Xd$fyd*~R(ChKDaQ5t4Dk7Bu|09W?klbGab#H<5kvlu@`B~iTe%b@5H_xFZ(TcH)NsC~VWg#jKhtn>ar~t~ zGN>tA9|SgOWf=?gu|W)BMT|WkpR$Z#X@e+2D9HLupv=czY_*h^AvRS!wJ9^me~@ww z`cj1*!T_X&{GpTdDx~aQp6|g|=9jXvK=d9I4pL(sBIht1lIwsBg+Rc%Jd;4S(Fo_r zp)Fjd@R3A8%1YB(&R^)KPk%@b(e6^N%-ry$M+jr73op<&9Sl0P(u09k6D;IISmba{ zsDU3F8D#{ylp!<(57@#K`q@*#zzF?%iXq|z8%eC#1V1VSofw&rD1@-j#1_1l(MBmV zHjAE7Ske+s<5f0s@0APDD>PCFeaJf?jL}CLFRX-2PaGp6|B?xaFw8aN7j7dkk*9+G z0-_tvS=^F^OSF6;VYw(;E=rb*lY5hff?FrPd?I=%vC=zti}w#@Uv~CF1J+((3(l zHPGJwrs*A1qG`|L6(YA!;`WI(T~bZgFZ5a)85t)RRLHjV55JhO9sJ7f*oFtYMcW$5 zwkBa)^LQW5bu(BJ)yLk6ya;^rcIQo?-E&5$UB7 z**{qaW_)YHu{>sgBi}Q7DciNMUtnxU)<&`<5629yvSh0Hg~nxc&bijW5|e)RjIDC)p1+V@KKy$SnX_Hlqdg0s6TQ8a-2NonH5W$BPteuFF82POMp!am4C z_d-SX70I(bQPhk3@z|3~(t$6?FK|VBpJeY#*!x)6Rnf87i}xlHMXPX&_D0Fxm|*uO zRF#+G8^5{rovkzHX2%|#``#DD`U6t^0eI7Ww)>;Am!6(QuUttCkBDde(pi7{Q_;(! zU6AZT!Y(9>VOk!YP|~A?d9LeZQFac(EeBpdar*>~C(-Va?4B83YVAy{xhUW0Lav8K z4%~?O6SIBV0-OCMvB4Sr>_DOt*1UE}mAf9loG>2}&4(oOAw002Yl;mf3Y&0?W{+g{ zB+MRWCzG=F4a_=WJ0vklbDv4QNOFGt)@Q5Gt zirk1c=_rn#kQ^&Nax^`4G|jkY_KJ=+$5cRzHlB#+S-FR!?TNx++%etP3%*u> zBVp>_EPPNX`@u!2+2_Z)KPmWe!Q)pFr(YEJyd>?x0Hs)o%jxflfh)3)XdaQwBMEl1 z>E)X#n3<58Fu#1bpOhs|UXl*=$Zv2(L%(F`PZ;_?b`^oeK=rp8t;~65?Mut=zj*h> z*wL8=v2?9ex;A2k07uihMCtbEDfXo!7QA!m-lgcJSuh}A1toSOb|Shjx^K2LS+z1* zRrB>#Ut1NgnYkubZIY@sB`d4HUiP&z2u{2pR<=o%ZSxLO1&RSDVu8tjd&CY&g=%iD zyc$EcVe5}J{BZm4?ffCs(ho`HhahwltB{K7AU_e~6ZkD%o~&3NdsV7vO_ncsk1R!h$lbuM(RWf}e;p~2E-Gj|v-|@8_-_|`eeaHDN=Oe=p?P61x)YO%LjW{N6 zF?n##V1DgTr0}0r)|4pTA{K9vinly=Ck&mUp;Iz+;(;~x%2-pvx)Qf&XpjsI ziOelCTFgBF$Aao?vf?NVqufg|=FmH^)HYwe;Ff4wDLG5!O>k zgCZpJJumw*CLDtSa)}qntU2a8O=(eBIrFbjEW6A!WWKrhY>TG6edxBbWNS6r9EWZF*vH^73J^4Z@$&_cn4^-TsW8 zF2c$19`h3Zcl4bVHkTZM972MeT%%bP&66wEVzHHR{*Dr1jq)pcqJ~ThZPU}=lfxQ< z#rYJk5IW7JnahWLFW2I`?zrHg-@--9!{j+xAnc#IClieBs5o82{va)RLWBOt{1RY5s)FlW3 zL8v1iXAf+$LY`N)DvL@oc$z`c3{?IN1QPC&^IPP6n;ga>{|kEhTXOz^oOyD7K@Q`m zlVB$^R||X(49~oX^ZVfGCd*`*sPxi5R%S;6^ipWvkWB8(Ou7c}>>2 zq}6Ea6Ph~Z{jtV5$^McW+a&u_JXj{#Us7Y8WPgfJNJn)iH5CcY+di;f=q1eGYuikqVtFc(zA{DoYnpR2En$WZ+HBE`d?sIU^x{}H8>v_fVRphsiD_JoLvqr#O*W- z+@|ANEjFM9?iHO*O9Hzp$Li8H&Er64rL)SQb+IE5Yhh_k?Htr<_GuxxyR&oN1S`Ji zsU_TDy=K$4d3~)Gf(eT_N42N5+Ew$K1@5p;W3WfYpXw_U`br#7J-P2o`(N8X$^L*; c8LHsTPF(oI?dH7(&5sSHy@mQ8JG5~BfBx`09RL6T literal 0 HcmV?d00001