Repository navigation
Expand file tree
/
Copy pathcore.py
More file actions
1572 lines (1403 loc) · 69.4 KB
/
Copy pathcore.py
File metadata and controls
1572 lines (1403 loc) · 69.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
from communication.work_client import WorkClient, telemetry_fields
from models.classifiers import ImageNet
from models.detectors import MaskRCNN
from vision.detectors.abstract_detector import AbstractDetector
from vision.classifiers.abstract_classifier import AbstractClassifier
from constructs.classification import Classification, LabelType
from constructs.roi import ROI
import argparse
import math
import PIL.Image as Image
from multiprocessing import Process
import socket
import os
import csv
import shutil
import threading
from pathlib import Path
from datetime import datetime
from http.server import ThreadingHTTPServer
from communication.intsys_gs_api import MapCommandHandler, FrontendHandler, ResultStore, EXPORT_DIR, notify_sse, _parse_label
import requests
import io
import base64
import json
from utils.helper import print_green, print_red, print_yellow
import time
import threading
import logging
from constructs.image_types import Base64Image, LabelTypes, ImageMeta, GeoLocation, CandidateImage
from constructs.geotagging import geotag_candidate
# Keep color only for section headers; all other logs are plain text.
header = print_green
logger = logging.getLogger(__name__)
# --- Autopilot send: ground-test altitude fallback ---------------------------
# geotag_candidate() returns None when location.alt <= 0 (constructs/geotagging.py),
# because the nadir projection scales ground offset linearly with altitude: at 0 m
# every pixel in the frame collapses onto the aircraft's own position. Meanwhile
# mini-plane-system reports MAVLink relative_alt (pixhawk/pixhawk.py), which sits at
# ~0 on the bench, so the manual "Send to Autopilot" button could never fire on the
# ground.
#
# When the reported AGL is non-positive we substitute a nominal altitude so the send
# still goes through. The resulting lat/lon is a TEST coordinate, not a real fix --
# its offset from the aircraft is fabricated from this constant. Set
# HAWKAI_FALLBACK_ALT_M=0 to restore the strict on-ground refusal.
DEFAULT_FALLBACK_AGL_M = 30.0
def _fallback_agl_m() -> float:
"""Configured stand-in AGL in metres, or 0.0 when the fallback is disabled."""
try:
value = float(os.environ.get("HAWKAI_FALLBACK_ALT_M", DEFAULT_FALLBACK_AGL_M))
except (TypeError, ValueError):
return DEFAULT_FALLBACK_AGL_M
return value if math.isfinite(value) and value > 0.0 else 0.0
def _resolve_agl(alt_raw):
"""Return (altitude_m, substituted) for the autopilot projection.
`substituted` is True when the telemetry altitude was unusable (non-positive,
missing or non-finite) and the fallback stood in for it. When the fallback is
disabled the unusable value is passed through so geotag_candidate() still
refuses the projection.
"""
try:
alt = float(alt_raw)
except (TypeError, ValueError):
alt = 0.0
if not math.isfinite(alt):
alt = 0.0
if alt > 0.0:
return alt, False
fallback = _fallback_agl_m()
if fallback <= 0.0:
return alt, False
return fallback, True
def _compute_target_geolocation(assignment: dict, bbox: list, image_path) -> tuple:
"""Compute geotagged target lat/lon from assignment telemetry and bbox.
Returns (target_lat, target_lon) or (None, None) if computation fails.
"""
try:
if not bbox or len(bbox) != 4:
return None, None
img_data = (assignment or {}).get('image') or {}
tel = img_data.get('telemetry') or {}
gps = tel.get('gps') or {}
lat = gps.get('latitude')
lon = gps.get('longitude')
alt = tel.get('altitude')
heading = tel.get('planeYaw') or tel.get('yaw') or 0.0
if lat is None or lon is None or alt is None:
return None, None
# Load image and compute geotag
with open(image_path, 'rb') as f:
b64 = base64.b64encode(f.read()).decode('utf-8')
meta = ImageMeta(
location=GeoLocation(lat=float(lat), lon=float(lon), alt=float(alt)),
heading=float(heading)
)
source = Base64Image(id=0, base64_image=b64, meta=meta)
candidate = CandidateImage(bbox=bbox, score=0, source=source, label=LabelTypes.UNKNOWN)
geo = geotag_candidate(candidate)
if geo:
return geo.lat, geo.lon
except Exception as e:
logger.debug(f"Geotag computation failed: {e}")
return None, None
def _ensure_export_dir() -> bool:
"""Ensure EXPORT_DIR exists, even if deleted mid-runtime."""
try:
EXPORT_DIR.mkdir(parents=True, exist_ok=True)
return True
except Exception as e:
print_red(f"[export] Failed to create export directory: {e}")
return False
# Path of this run's log file, published module-level so the log-panel source
# resolver can reach it (the filename embeds the start timestamp).
CURRENT_LOG_PATH: Path = None
def _setup_file_logging() -> Path:
global CURRENT_LOG_PATH
logs_dir = Path(__file__).parent / "logs"
logs_dir.mkdir(exist_ok=True)
date_str = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
log_path = logs_dir / f"local_hawk_ai_{date_str}.log"
CURRENT_LOG_PATH = log_path
handler = logging.FileHandler(log_path)
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)-8s %(name)s — %(message)s"))
logging.getLogger().addHandler(handler)
logging.getLogger().setLevel(logging.INFO)
return log_path
GD_model = None
_GD_MODEL_INIT_ATTEMPTED = False
_GD_MODEL_LOCK = threading.Lock()
def _get_gd_model():
"""
Lazily initialize GroundingDINO once.
This avoids heavy model/bootstrap work (and HF network calls) when running
mapping-only mode, where gd_backup is never used.
"""
global GD_model, _GD_MODEL_INIT_ATTEMPTED
if GD_model is not None:
return GD_model
if _GD_MODEL_INIT_ATTEMPTED:
return None
with _GD_MODEL_LOCK:
if GD_model is not None:
return GD_model
if _GD_MODEL_INIT_ATTEMPTED:
return None
_GD_MODEL_INIT_ATTEMPTED = True
try:
from constructs.detection import GDDetection
GD_model = GDDetection()
return GD_model
except Exception as e:
print_red(f"[gd_backup] GroundingDINO init failed: {e}")
GD_model = None
return None
MAX_BOX_FRACTION = 0.5
# Mapping pipeline constants (mirrors hawk-ai/main.py)
MAPPING_SESSION_DIR = Path(__file__).parent / "mapping" / "current_session"
MAPPING_OUTPUT_DIR = Path(__file__).parent / "mapping" / "outputs"
# Running partial composite, kept out of outputs/ so it can never be mistaken
# for a finished map by anyone browsing that folder.
MAPPING_PREVIEW_PATH = Path(__file__).parent / "mapping" / "preview" / "stitch_progress.jpg"
MAPPING_CSV_PATH = MAPPING_SESSION_DIR / "metadata.csv"
IDLE_MAPPING_TIMEOUT_SECONDS = 20
IDLE_MAPPING_POLL_SECONDS = 1
# How often to ask the cloud whether its mapping run has finished. Cloud stitches
# take minutes, so this is deliberately slower than the idle monitor's 1s tick.
CLOUD_MAP_POLL_SECONDS = 15
# Mapping Helper Functions (mirrors hawk-ai/main.py, adapted for PIL images) --------
def _reset_session() -> None:
"""Clear the current session folder and write a fresh CSV header."""
shutil.rmtree(MAPPING_SESSION_DIR, ignore_errors=True)
(MAPPING_SESSION_DIR / "images").mkdir(parents=True, exist_ok=True)
with open(MAPPING_CSV_PATH, "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=[
"Image", "Latitude", "Longitude", "Altitude", "Degrees_Clockwise_from_North"
])
writer.writeheader()
def _count_csv_rows() -> int:
"""Return number of data rows in the metadata CSV (0 if file missing)."""
try:
with open(MAPPING_CSV_PATH, "r") as f:
return max(0, sum(1 for _ in f) - 1)
except Exception:
return 0
def _save_image_for_mapping_local(image: Image.Image, metadata: dict) -> bool:
"""
Save a PIL image + GPS telemetry to the mapping session folder and append
a row to metadata.csv. Returns False if no GPS could be found for the image.
Field mapping: telemetry.planeYaw -> Degrees_Clockwise_from_North (same
meaning as hawk-ai's image.meta.heading). The flat "latitude"/"longitude"/
"yaw" keys this used to read do not exist in gs-backend's payload, so every
image was silently dropped and the session never held anything. See
work_client.telemetry_fields for the shape gs-backend actually sends.
"""
lat, lon, alt, yaw = telemetry_fields((metadata or {}).get("telemetry"))
if lat is None or lon is None:
print_yellow(f"[mapping] Skipped image {(metadata or {}).get('id')}: no GPS in telemetry")
return False
if alt is None:
alt = 0
try:
image_id = metadata["id"]
img_filename = f"{image_id}.jpg"
img_path = MAPPING_SESSION_DIR / "images" / img_filename
image.save(str(img_path), format="JPEG")
with open(MAPPING_CSV_PATH, "a", newline="") as f:
writer = csv.DictWriter(f, fieldnames=[
"Image", "Latitude", "Longitude", "Altitude", "Degrees_Clockwise_from_North"
])
writer.writerow({
"Image": img_filename,
"Latitude": lat,
"Longitude": lon,
"Altitude": alt,
"Degrees_Clockwise_from_North": yaw,
})
print(f"[mapping] Saved {img_filename} to session")
return True
except Exception as e:
print_red(f"[mapping] Failed to save image {metadata.get('id')}: {e}")
return False
def _run_pipeline_local(mapper) -> None:
"""
Run GpsSiftPipeline in a background thread (local-hawk-ai is synchronous,
no asyncio event loop). Called via threading.Thread from mapper.trigger_pipeline().
Mirrors hawk-ai's _run_pipeline + _run_mapping logic.
"""
from mapping.main_gps_sift import GpsSiftPipeline
# Drop any preview left by the previous flight before announcing this run,
# so /api/mapping/preview can never serve a stale map as if it were the
# current one.
try:
MAPPING_PREVIEW_PATH.unlink()
except FileNotFoundError:
pass
except Exception as e:
print_yellow(f"[mapping] Could not clear old preview: {e}")
mapper.set_progress("starting")
mapper.mapping_running = True
n_images = _count_csv_rows()
if n_images < 2:
mapper.set_progress("skipped")
print_yellow(f"[mapping] Only {n_images} image(s) in session — skipping pipeline")
_reset_session()
mapper.mapping_running = False
return
print_green(f"[mapping] Trigger received — running pipeline on {n_images} images...")
try:
date_str = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
final_out = str(MAPPING_OUTPUT_DIR / f"map_{date_str}.jpg")
pipeline = GpsSiftPipeline(output_dir=str(MAPPING_OUTPUT_DIR))
raw_path = pipeline.run(
str(MAPPING_SESSION_DIR / "images"),
str(MAPPING_CSV_PATH),
progress_cb=mapper.set_progress,
preview_path=str(MAPPING_PREVIEW_PATH),
)
Path(raw_path).rename(final_out)
mapper.mapping_result = final_out
mapper.set_progress("done")
print(f"[mapping] Done → {final_out}")
except Exception as e:
mapper.set_progress("error", detail=str(e))
print_red(f"[mapping] Pipeline error: {e}")
finally:
_reset_session()
mapper.mapping_running = False
class Mapper:
def __init__(self, work_client : WorkClient):
self.work_client = work_client
self.mapping = False
if not os.path.exists('images.csv'):
with open('images.csv', 'w') as f:
f.write('Image,Latitude,Longitude,Altitude,Degrees_Clockwise_from_North\n')
# Hawk-ai-style mapping state
self.mapping_running = False
self.mapping_result = None
self.last_image_received_ts = time.time()
self.last_auto_trigger_ts = 0.0
# Cloud mapping mirror state. cloud_mapping_result is the local path of
# the most recently downloaded cloud map; _seen_cloud_results tracks the
# cloud-side paths we have already pulled so a steady-state poll does not
# re-download the same map every tick.
self.cloud_mapping_result = None
self._seen_cloud_results = set()
# Live progress for the local stitch. Written from the pipeline thread
# via set_progress and read from HTTP handler threads, so it is guarded
# and always replaced wholesale rather than mutated field by field.
# Handed to the HTTP layer through this attribute rather than imported:
# core imports intsys_gs_api, so the handler cannot import back.
self.preview_path = MAPPING_PREVIEW_PATH
self._progress_lock = threading.Lock()
self._progress = {"phase": "idle", "done": None, "total": None,
"detail": None, "started_ts": None, "updated_ts": None}
# Result of the most recent attempt to trigger the cloud's own stitch,
# surfaced next to the local progress so one panel covers both.
self.cloud_trigger_status = None
def trigger_pipeline(self, include_cloud: bool = True):
"""Trigger the local GpsSiftPipeline, and by default the cloud's too.
The two sessions are independent, so a single operator action has to
start both or the cloud map silently never gets made. The cloud call is
fired first and on its own thread: it is best-effort, and an
unreachable cloud must not stop the local stitch from running.
A local run already in progress does not block the cloud trigger - the
cloud tracks its own run state and answers 409 itself if it is busy.
"""
if include_cloud:
self.trigger_cloud_pipeline()
if self.mapping_running:
print_yellow("[mapping] Local pipeline already running — ignoring local trigger")
return
threading.Thread(target=_run_pipeline_local, args=(self,), daemon=True).start()
print("[mapping] Pipeline triggered in background thread")
def set_progress(self, phase: str, done=None, total=None, detail=None):
"""Record pipeline progress. Called from the stitcher's own thread as a
progress_cb, and directly for the phases core.py owns.
`starting` stamps the run's start time; every other phase keeps it, so
the dashboard can show elapsed time across the whole run rather than
per phase.
"""
now = time.time()
with self._progress_lock:
started = now if phase == "starting" else self._progress.get("started_ts")
self._progress = {
"phase": phase,
"done": done,
"total": total,
"detail": detail,
"started_ts": started,
"updated_ts": now,
}
def get_progress(self) -> dict:
"""Snapshot of local stitch progress, plus whether a partial map exists.
`preview_available` is resolved at read time rather than tracked, so a
preview deleted underneath us is reported honestly.
"""
with self._progress_lock:
snap = dict(self._progress)
snap["running"] = self.mapping_running
snap["last_result"] = self.mapping_result
snap["cloud_last_result"] = self.cloud_mapping_result
snap["cloud_trigger"] = self.cloud_trigger_status
snap["images_queued"] = _count_csv_rows()
try:
# self.preview_path, not the module constant: the HTTP preview route
# serves this same attribute, and two readers of "where the preview
# lives" that can disagree is exactly how the mapping session came
# to be silently empty in the first place.
snap["preview_available"] = self.preview_path.exists()
snap["preview_ts"] = (self.preview_path.stat().st_mtime
if snap["preview_available"] else None)
except Exception:
snap["preview_available"] = False
snap["preview_ts"] = None
return snap
def trigger_cloud_pipeline(self):
"""Ask the cloud to stitch its own session.
The cloud keeps a completely separate mapping session fed by
POST /api/images, so its run has to be started separately - triggering
the local pipeline does nothing to it. Runs on its own thread because
the HTTP call can block for the full request timeout and must not hold
up the local trigger it is fired alongside.
"""
def _go():
resp = self.work_client.trigger_cloud_mapping()
self.cloud_trigger_status = resp
if resp.get("ok"):
print_green(f"[mapping] Cloud stitch triggered ({resp.get('detail')})")
else:
print_yellow(f"[mapping] Cloud stitch not triggered: {resp.get('detail')}")
threading.Thread(target=_go, daemon=True, name="cloud-map-trigger").start()
def mark_image_received(self):
"""Record timestamp of the latest received image."""
self.last_image_received_ts = time.time()
def poll_cloud_map_once(self):
"""Pull the cloud orthomosaic down once a cloud run has finished.
Saved alongside the locally-produced maps in MAPPING_OUTPUT_DIR under the
cloud's own filename with `_cloud` appended, so `map_<date>.jpg` on the
cloud lands here as `map_<date>_cloud.jpg` and sorts next to the local
map from the same flight.
Only downloads once the cloud reports `running: false` — mapping_result
is set before the run flips that flag back, so pulling while it is still
true can fetch the *previous* flight's map.
"""
cloud_status = self.work_client.get_cloud_mapping_status()
if not cloud_status:
return
if cloud_status.get("running"):
return
remote_path = cloud_status.get("last_result")
if not remote_path or remote_path in self._seen_cloud_results:
return
remote_name = Path(remote_path).name
if not remote_name:
return
stem = Path(remote_name).stem
suffix = Path(remote_name).suffix or ".jpg"
dest = MAPPING_OUTPUT_DIR / f"{stem}_cloud{suffix}"
# Mark as seen up front: on a persistent failure we would otherwise
# retry this same map on every tick for the rest of the flight.
self._seen_cloud_results.add(remote_path)
if dest.exists():
print(f"[mapping] Cloud map already present locally → {dest}")
self.cloud_mapping_result = str(dest)
return
print_green(f"[mapping] Cloud run finished ({remote_name}) — downloading...")
if self.work_client.download_cloud_mapping_result(dest):
self.cloud_mapping_result = str(dest)
else:
print_yellow(f"[mapping] Could not download cloud map {remote_name}")
def maybe_trigger_pipeline_on_idle(self, timeout_seconds: float = IDLE_MAPPING_TIMEOUT_SECONDS):
"""
Auto-trigger mapping when image ingest has been idle for long enough.
Requires at least 2 images in session and no active pipeline.
"""
if self.mapping_running:
return
if time.time() - self.last_image_received_ts < timeout_seconds:
return
n_images = _count_csv_rows()
if n_images < 2:
return
if time.time() - self.last_auto_trigger_ts < timeout_seconds:
return
print(
f"[mapping] Idle for {timeout_seconds}s with {n_images} images; auto-triggering pipeline"
)
self.last_auto_trigger_ts = time.time()
# Local only: the idle heuristic fires repeatedly and unattended, and
# should not be POSTing at the cloud on its own.
self.trigger_pipeline(include_cloud=False)
class VisionClient:
def __init__(self, work_client : WorkClient, mapper : Mapper, result_store: ResultStore, autopilot_host: str = None, result_interval_seconds: float = 10.0):
header("\n[vision] Initializing Work Client")
self.work_client = work_client
# print("Getting target attributes")
# self.target_attr = self.work_client.get_target_attributes()
header("[vision] Initializing Mapper")
self.mapper = mapper
self.result_store = result_store
# Autopilot configuration
self.autopilot_host = autopilot_host
self.autopilot_url = None
if autopilot_host:
# Project-Emu's mavproxy_cuairapi exposes POST /targets_set, not /target
# (mavproxy_cuairapi/views/targets.py) — no /target route exists anywhere.
self.autopilot_url = f"http://{autopilot_host}/targets_set"
# incremental id for autopilot messages
self._autopilot_id = 1
self.result_interval_seconds = max(1.0, float(result_interval_seconds))
self._send_lock = threading.Lock()
# Single background thread: poll cloud for results, then send to autopilot
self._result_scheduler_thread = threading.Thread(
target=self._result_scheduler_loop,
daemon=True,
)
self._result_scheduler_thread.start()
# Track signatures of cloud detections we've already seen to
# avoid duplicate processing (assignment id, bbox, score).
self._seen_cloud_signatures = set()
# GD backup: best detected candidate per label from the most recent image
self._gd_best_mannequin: tuple = None # (assignment, ROI, Classification)
self._gd_best_tent: tuple = None # (assignment, ROI, Classification)
# "Session start" (T0): receive-timestamp of the first image pulled while
# EXPORT_DIR was empty. Re-established any time the folder empties again
# (e.g. after a "clear_exports" command), since request_image() re-checks
# disk state on every pull rather than latching a one-time flag.
self._session_start_ts: int = None
self._session_start_lock = threading.Lock()
def _result_scheduler_loop(self):
while True:
remaining = int(self.result_interval_seconds)
while remaining > 0:
# Log every 5 seconds, then every second in the last 5 seconds.
if remaining <= 5 or remaining % 5 == 0:
print_yellow(f"[scheduler] Next cloud poll in {remaining}s")
time.sleep(1)
remaining -= 1
# Poll cloud once for new best images. Autopilot sends are no longer
# automatic here — they only happen when a user presses "Send to
# Autopilot" on a dashboard card (see send_meta_to_autopilot()).
try:
self._poll_cloud_once()
except Exception as e:
print_yellow(f"[scheduler] Cloud poll failed: {e}")
# loop repeats after interval
def _build_candidate_from_entry(self, assignment: dict, roi: ROI, classification: Classification, meta_filename: str = None, base64_override: str = None):
"""Build a CandidateImage suitable for geotag_candidate().
Returns (candidate, alt_substituted), where alt_substituted is True when the
assignment's telemetry altitude was unusable and _resolve_agl() supplied a
stand-in so an on-ground manual send can still be projected.
"""
# Try to get base64 full image from meta file
b64 = None
try:
if base64_override:
b64 = base64_override
elif meta_filename:
mfpath = EXPORT_DIR / meta_filename
if mfpath.exists():
with open(mfpath, 'r') as _mf:
try:
m = json.load(_mf)
except Exception:
m = {}
full_fname = m.get('full_image')
if full_fname and (EXPORT_DIR / full_fname).exists():
with open(EXPORT_DIR / full_fname, 'rb') as _fimg:
import base64 as _b64
b64 = 'data:image/jpeg;base64,' + _b64.b64encode(_fimg.read()).decode('utf-8')
except Exception:
b64 = None
# Fallback: fetch from GS via assignment
if not b64 and assignment and isinstance(assignment, dict):
try:
img_endpoint = None
if isinstance(assignment.get('image'), dict):
img_endpoint = assignment.get('image').get('imageUrl') or assignment.get('image').get('localImageUrl')
if img_endpoint and getattr(self, 'mapper', None) and getattr(self.mapper, 'work_client', None):
fetched = self.mapper.work_client.get_image(img_endpoint)
if fetched is not None:
buf = io.BytesIO()
fetched.save(buf, format='JPEG')
import base64 as _b64
b64 = 'data:image/jpeg;base64,' + _b64.b64encode(buf.getvalue()).decode('utf-8')
except Exception:
b64 = None
# Build ImageMeta from assignment telemetry (best-effort)
meta = None
alt_substituted = False
try:
lat = None; lon = None; alt = None; heading = 0.0
if assignment and isinstance(assignment, dict):
img = assignment.get('image') or {}
tel = img.get('telemetry') or img.get('meta') or {}
gps = tel.get('gps') or {}
lat = gps.get('latitude') or tel.get('latitude') or tel.get('lat')
lon = gps.get('longitude') or tel.get('longitude') or tel.get('lon')
alt = tel.get('altitude') or gps.get('altitude') or tel.get('alt')
heading = tel.get('yaw') or tel.get('planeYaw') or 0.0
if lat is not None and lon is not None:
agl, alt_substituted = _resolve_agl(alt)
meta = ImageMeta(location=GeoLocation(lat=float(lat), lon=float(lon), alt=agl), heading=float(heading))
except Exception:
meta = None
alt_substituted = False
base_src = Base64Image(id=assignment.get('id') if isinstance(assignment, dict) else 0, base64_image=b64 or '', meta=meta, assignment=assignment)
bbox = []
if roi is not None:
try:
bbox = [int(roi.top_left[0]), int(roi.top_left[1]), int(roi.bottom_right[0]), int(roi.bottom_right[1])]
except Exception:
bbox = []
score = 0.0
try:
score = float(classification.label[1]) if classification is not None else 0.0
except Exception:
score = 0.0
cand = CandidateImage(bbox=bbox, score=score, source=base_src, label=LabelTypes.UNKNOWN)
return cand, alt_substituted
def send_entry_to_autopilot(self, entry, target_type_str: str) -> dict:
"""Build a candidate from `entry`, project it, and POST it to the autopilot endpoint.
`entry` is (assignment, roi, classification, model_source, gemini_reason, meta_filename).
Returns {"status": "success"|"error", "message": str} for callers (e.g. the manual
"Send to Autopilot" button) that need to relay the outcome back to a user.
"""
if not self.autopilot_url:
msg = "Autopilot URL not configured; skipping send"
print_yellow(f"[autopilot] {msg}")
return {"status": "error", "message": msg}
if entry is None:
msg = f"No {target_type_str} entry to send"
print_yellow(f"[autopilot] {msg}")
return {"status": "error", "message": msg}
try:
assignment = entry[0]
roi = entry[1]
classification = entry[2]
meta_fn = entry[5] if len(entry) > 5 else None
cand = None
alt_substituted = False
try:
cand, alt_substituted = self._build_candidate_from_entry(assignment, roi, classification, meta_filename=meta_fn)
except Exception as e:
print_red(f"[autopilot] Failed to build candidate for projection: {e}")
cand = None
if alt_substituted:
print_yellow(
f"[autopilot] Telemetry AGL was <= 0 (aircraft on the ground?); "
f"projecting with a {_fallback_agl_m():.1f} m fallback - the "
f"coordinate below is for testing, not a real target fix"
)
lat = None; lon = None
if cand is not None:
try:
geo = geotag_candidate(cand)
if geo:
lat = geo.lat
lon = geo.lon
except Exception as e:
print_red(f"[autopilot] Geotagging error: {e}")
if lat is None or lon is None:
msg = f"Could not determine lat/lon for {target_type_str}; skipping send"
print_red(f"[autopilot] {msg}")
return {"status": "error", "message": msg}
# Shape required by Project-Emu's pathplanning.update_targets(), which
# reads target['geotag']['gpsLocation']['latitude'/'longitude'] — a flat
# {lat, lng, ...} body raises a KeyError there (caught, returns 500).
payload = {
"id": int(self._autopilot_id),
"geotag": {
"id": int(self._autopilot_id),
"gpsLocation": {
"latitude": float(lat),
"longitude": float(lon),
},
},
"target_type": target_type_str.upper(),
}
print("PAYLOAD:", payload)
try:
# Use WorkClient's retry helper for autopilot POSTs to benefit from same retry/backoff logic
resp = self.work_client._do_request_with_retries('post', self.autopilot_url, json=payload, timeout=5)
except Exception as e:
msg = f"Failed to POST to autopilot after retries: {e}"
print_red(f"[autopilot] {msg}")
return {"status": "error", "message": msg}
if resp is not None and 200 <= resp.status_code < 300:
print(f"[autopilot] Sent {target_type_str} -> {self.autopilot_url} (id={self._autopilot_id}, status={resp.status_code})")
self._autopilot_id += 1
note = " [TEST ALT]" if alt_substituted else ""
return {"status": "success", "message": f"Sent {target_type_str} to autopilot (lat={lat:.6f}, lon={lon:.6f}){note}"}
else:
status = resp.status_code if resp is not None else "no response"
msg = f"Autopilot rejected payload (status={status})"
print_red(f"[autopilot] {msg}")
return {"status": "error", "message": msg}
except Exception as e:
msg = f"Unexpected error processing entry: {e}"
print_red(f"[autopilot] {msg}")
return {"status": "error", "message": msg}
def send_meta_to_autopilot(self, meta_filename: str) -> dict:
"""Send one specific exported detection (by meta filename) to autopilot.
This is the entry point for the dashboard's manual "Send to Autopilot"
button, since autopilot sends are no longer automatic. Rebuilds the
ROI/Classification from the meta JSON + its full image on disk, the
same way ResultStore.rebuild_from_disk() does when restoring state.
"""
safe_name = Path(meta_filename or "").name
if not safe_name.startswith("meta_") or not safe_name.endswith(".json"):
return {"status": "error", "message": "Invalid meta_filename"}
meta_path = EXPORT_DIR / safe_name
if not meta_path.exists():
return {"status": "error", "message": f"Meta file not found: {safe_name}"}
with self._send_lock:
try:
with open(meta_path, "r") as f:
meta = json.load(f)
label = _parse_label(meta.get("label"))
if label == LabelType.TENT:
target_type_str = "tent"
elif label == LabelType.MANNEQUIN:
target_type_str = "person"
else:
return {"status": "error", "message": f"Unknown label in meta file: {meta.get('label')}"}
bbox = meta.get("bbox") or []
full_image_name = meta.get("full_image")
if len(bbox) != 4 or not full_image_name:
return {"status": "error", "message": "Meta file missing bbox or full_image"}
full_path = EXPORT_DIR / full_image_name
if not full_path.exists():
return {"status": "error", "message": f"Full image not found: {full_image_name}"}
x1, y1, x2, y2 = [int(v) for v in bbox]
full_image = Image.open(full_path).convert("RGB")
width, height = full_image.size
x1 = max(0, min(x1, width - 1))
y1 = max(0, min(y1, height - 1))
x2 = max(0, min(x2, width))
y2 = max(0, min(y2, height))
if x2 <= x1 or y2 <= y1:
return {"status": "error", "message": "Degenerate bbox in meta file"}
roi = ROI(roi=full_image.crop((x1, y1, x2, y2)), top_left=(x1, y1), bottom_right=(x2, y2))
classification = Classification(label=label, number_conf=float(meta.get("score", 0.0)))
entry = (
meta.get("assignment"),
roi,
classification,
meta.get("model_source", ""),
meta.get("gemini_reason"),
safe_name,
)
return self.send_entry_to_autopilot(entry, target_type_str)
except Exception as e:
msg = f"Failed to send meta file to autopilot: {e}"
print_red(f"[autopilot] {msg}")
return {"status": "error", "message": msg}
def _poll_cloud_once(self):
"""Perform a single poll of the cloud for mannequin and tent best images.
"""
# Mannequin
try:
assign, roi, clf, gemini_reason, model_source, full_image = self.work_client.get_mannequin_image()
if assign is not None and roi is not None and clf is not None:
# Detect duplicate: same assignment id, bbox, and confidence
try:
aid = assign.get('id') if assign else 'noid'
bbox = list(roi.top_left) + list(roi.bottom_right) if roi is not None else []
score = float(clf.label[1]) if clf is not None else 0.0
sig = (str(aid), tuple(bbox), round(float(score), 6))
except Exception:
sig = None
is_dup = (sig is not None and sig in self._seen_cloud_signatures)
if is_dup:
# Duplicate detected — notify frontend and skip updating result_store
try:
notify_sse('duplicate', {'label': 'mannequin', 'assignment_id': aid, 'bbox': bbox, 'score': score, 'message': 'duplicate detection — skipped autopilot send'})
except Exception:
pass
# proceed with persisting and recording only if not duplicate
if not is_dup:
if not _ensure_export_dir():
print_red("[poller] Skipping mannequin export write: export directory unavailable")
return
ts = int(time.time() * 1000)
label_name = 'mannequin'
aid = assign.get('id') if assign else 'noid'
full_fn = EXPORT_DIR / f"full_{label_name}_{aid}_{ts}.jpg"
roi_fn = EXPORT_DIR / f"roi_{label_name}_{aid}_{ts}.jpg"
# Save the true full image (as returned by the cloud) so the dashboard
# can show it with the bbox overlay, plus the ROI crop separately.
try:
import os as _os
import io as _io
buf = _io.BytesIO()
(full_image if full_image is not None else roi.image).save(buf, format='JPEG')
data = buf.getvalue()
tmp_full = str(full_fn) + '.tmp'
with open(tmp_full, 'wb') as _f:
_f.write(data)
_f.flush()
_os.fsync(_f.fileno())
_os.replace(tmp_full, str(full_fn))
except Exception:
pass
try:
import os as _os
import io as _io
buf2 = _io.BytesIO()
roi.image.save(buf2, format='JPEG')
data2 = buf2.getvalue()
tmp_roi = str(roi_fn) + '.tmp'
with open(tmp_roi, 'wb') as _f2:
_f2.write(data2)
_f2.flush()
_os.fsync(_f2.fileno())
_os.replace(tmp_roi, str(roi_fn))
except Exception:
pass
session_start_ts, since_session_start_ms = self._since_session_start(ts)
bbox = list(roi.top_left) + list(roi.bottom_right)
target_lat, target_lon = _compute_target_geolocation(assign, bbox, full_fn)
meta = {
"timestamp": ts,
"label": label_name,
"assignment_id": aid,
"assignment": assign,
"model_source": model_source or "cloud_pull",
"gemini_reason": gemini_reason,
"score": float(clf.label[1]) if clf is not None else 0.0,
"full_image": str(full_fn.name),
"roi_image": str(roi_fn.name),
"bbox": bbox,
"pushed": True,
"session_start_ts": session_start_ts,
"since_session_start_ms": since_session_start_ms,
"target_lat": target_lat,
"target_lon": target_lon,
}
try:
import os as _os
meta_fn = EXPORT_DIR / f"meta_{label_name}_{aid}_{ts}.json"
tmp_meta = str(meta_fn) + '.tmp'
with open(tmp_meta, 'w') as _mf:
json.dump(meta, _mf)
_mf.flush()
_os.fsync(_mf.fileno())
_os.replace(tmp_meta, str(meta_fn))
mf_name = meta_fn.name
except Exception:
mf_name = None
# Mark signature seen to avoid future duplicates
try:
if sig is not None:
self._seen_cloud_signatures.add(sig)
except Exception:
pass
try:
# Update result store and notify frontend of the new cloud pull
self.result_store.update(LabelType.MANNEQUIN, assign, roi, clf, "cloud_pull", None, mf_name)
print_green("[poller] Updated mannequin from cloud pull")
try:
# small pause to ensure filesystem visibility after atomic rename
time.sleep(0.05)
notify_sse('gs_pull', {'label': 'mannequin', 'meta': mf_name})
except Exception:
pass
except Exception as e:
print_red(f"[poller] Failed to update mannequin result_store: {e}")
except Exception as e:
print_yellow(f"[poller] Mannequin pull error: {e}")
# Tent
try:
assign, roi, clf, gemini_reason, model_source, full_image = self.work_client.get_tent_image()
if assign is not None and roi is not None and clf is not None:
# Duplicate detection for tent
try:
aid = assign.get('id') if assign else 'noid'
bbox = list(roi.top_left) + list(roi.bottom_right) if roi is not None else []
score = float(clf.label[1]) if clf is not None else 0.0
sig = (str(aid), tuple(bbox), round(float(score), 6))
except Exception:
sig = None
is_dup = (sig is not None and sig in self._seen_cloud_signatures)
if is_dup:
try:
notify_sse('duplicate', {'label': 'tent', 'assignment_id': aid, 'bbox': bbox, 'score': score, 'message': 'duplicate detection — skipped autopilot send'})
except Exception:
pass
if not is_dup:
try:
if not _ensure_export_dir():
print_red("[poller] Skipping tent export write: export directory unavailable")
return
ts = int(time.time() * 1000)
label_name = 'tent'
aid = assign.get('id') if assign else 'noid'
full_fn = EXPORT_DIR / f"full_{label_name}_{aid}_{ts}.jpg"
roi_fn = EXPORT_DIR / f"roi_{label_name}_{aid}_{ts}.jpg"
# Save the true full image (as returned by the cloud) so the dashboard
# can show it with the bbox overlay, plus the ROI crop separately.
try:
import os as _os
import io as _io
buf = _io.BytesIO()
(full_image if full_image is not None else roi.image).save(buf, format='JPEG')
data = buf.getvalue()
tmp_full = str(full_fn) + '.tmp'
with open(tmp_full, 'wb') as _f:
_f.write(data)
_f.flush()
_os.fsync(_f.fileno())
_os.replace(tmp_full, str(full_fn))
except Exception:
pass
try:
import os as _os
import io as _io
buf2 = _io.BytesIO()
roi.image.save(buf2, format='JPEG')
data2 = buf2.getvalue()
tmp_roi = str(roi_fn) + '.tmp'
with open(tmp_roi, 'wb') as _f2:
_f2.write(data2)
_f2.flush()
_os.fsync(_f2.fileno())
_os.replace(tmp_roi, str(roi_fn))
except Exception:
pass
try:
import os as _os
meta_fn = EXPORT_DIR / f"meta_{label_name}_{aid}_{ts}.json"
tmp_meta = str(meta_fn) + '.tmp'
session_start_ts, since_session_start_ms = self._since_session_start(ts)
bbox = list(roi.top_left) + list(roi.bottom_right)
target_lat, target_lon = _compute_target_geolocation(assign, bbox, full_fn)
with open(tmp_meta, 'w') as _mf:
json.dump({
"timestamp": ts,
"label": label_name,
"assignment_id": aid,
"assignment": assign,
"model_source": model_source or "cloud_pull",
"gemini_reason": gemini_reason,
"score": float(clf.label[1]) if clf is not None else 0.0,
"full_image": str(full_fn.name),
"roi_image": str(roi_fn.name),
"bbox": bbox,
"pushed": True,
"session_start_ts": session_start_ts,
"since_session_start_ms": since_session_start_ms,
"target_lat": target_lat,
"target_lon": target_lon,
}, _mf)
_mf.flush()
_os.fsync(_mf.fileno())
_os.replace(tmp_meta, str(meta_fn))
mf_name = meta_fn.name
except Exception: