api.py 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. import os
  2. import time
  3. import threading
  4. import numpy as np
  5. import cv2
  6. from fastapi import FastAPI, HTTPException
  7. from pyorbbecsdk import *
  8. ESC_KEY = 27
  9. PRINT_INTERVAL = 1 # seconds
  10. MEDIAN_BLUR_KSIZE = 5 # odd number, 0 to disable
  11. MORPH_OPEN_KSIZE = 3 # odd number, 0 to disable
  12. NEAREST_PERCENTILE = 5 # use low percentile to suppress isolated noise (0 for raw min)
  13. SAMPLE_COUNT = 10
  14. FRAME_TIMEOUT_MS = 200
  15. SAMPLE_TIMEOUT_SEC = 8
  16. def _get_env_int(name, default):
  17. value = os.getenv(name)
  18. if value is None or value.strip() == "":
  19. return default
  20. try:
  21. return int(value)
  22. except ValueError:
  23. return default
  24. MIN_DEPTH = _get_env_int("MIN_DEPTH", 500) # mm
  25. MAX_DEPTH = _get_env_int("MAX_DEPTH", 4000) # mm
  26. ROI_WIDTH_CM = _get_env_int("ROI_WIDTH_CM", 10) # cm
  27. ROI_HEIGHT_CM = _get_env_int("ROI_HEIGHT_CM", 12) # cm
  28. app = FastAPI(title="Cargo Height API")
  29. class TemporalFilter:
  30. def __init__(self, alpha):
  31. self.alpha = alpha
  32. self.previous_frame = None
  33. def process(self, frame):
  34. if self.previous_frame is None:
  35. result = frame
  36. else:
  37. result = cv2.addWeighted(frame, self.alpha, self.previous_frame, 1 - self.alpha, 0)
  38. self.previous_frame = result
  39. return result
  40. _pipeline = None
  41. _depth_intrinsics = None
  42. _temporal_filter = None
  43. _lock = threading.Lock()
  44. def _init_camera():
  45. global _pipeline, _depth_intrinsics, _temporal_filter
  46. if _pipeline is not None:
  47. return
  48. config = Config()
  49. pipeline = Pipeline()
  50. try:
  51. profile_list = pipeline.get_stream_profile_list(OBSensorType.DEPTH_SENSOR)
  52. if profile_list is None:
  53. raise RuntimeError("depth profile list is empty")
  54. depth_profile = profile_list.get_default_video_stream_profile()
  55. if depth_profile is None:
  56. raise RuntimeError("default depth profile is empty")
  57. _depth_intrinsics = depth_profile.get_intrinsic()
  58. config.enable_stream(depth_profile)
  59. except Exception as exc:
  60. raise RuntimeError(f"Failed to init depth camera: {exc}") from exc
  61. pipeline.start(config)
  62. _pipeline = pipeline
  63. _temporal_filter = TemporalFilter(alpha=0.5)
  64. def _shutdown_camera():
  65. global _pipeline
  66. if _pipeline is None:
  67. return
  68. _pipeline.stop()
  69. _pipeline = None
  70. def _measure_once():
  71. frames = _pipeline.wait_for_frames(FRAME_TIMEOUT_MS)
  72. if frames is None:
  73. return None
  74. depth_frame = frames.get_depth_frame()
  75. if depth_frame is None:
  76. return None
  77. depth_format = depth_frame.get_format()
  78. if depth_format != OBFormat.Y16:
  79. return None
  80. width = depth_frame.get_width()
  81. height = depth_frame.get_height()
  82. scale = depth_frame.get_depth_scale()
  83. depth_data = np.frombuffer(depth_frame.get_data(), dtype=np.uint16)
  84. depth_data = depth_data.reshape((height, width))
  85. depth_data = depth_data.astype(np.float32) * scale
  86. depth_data = np.where((depth_data > MIN_DEPTH) & (depth_data < MAX_DEPTH), depth_data, 0)
  87. depth_data = depth_data.astype(np.uint16)
  88. if MEDIAN_BLUR_KSIZE and MEDIAN_BLUR_KSIZE % 2 == 1:
  89. depth_data = cv2.medianBlur(depth_data, MEDIAN_BLUR_KSIZE)
  90. if MORPH_OPEN_KSIZE and MORPH_OPEN_KSIZE % 2 == 1:
  91. kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (MORPH_OPEN_KSIZE, MORPH_OPEN_KSIZE))
  92. valid_mask = (depth_data > 0).astype(np.uint8)
  93. valid_mask = cv2.morphologyEx(valid_mask, cv2.MORPH_OPEN, kernel)
  94. depth_data = np.where(valid_mask > 0, depth_data, 0).astype(np.uint16)
  95. depth_data = _temporal_filter.process(depth_data)
  96. center_y = height // 2
  97. center_x = width // 2
  98. center_distance = depth_data[center_y, center_x]
  99. if center_distance == 0:
  100. return None
  101. center_distance_m = center_distance / 1000.0
  102. half_width_m = (ROI_WIDTH_CM / 100.0) / 2.0
  103. half_height_m = (ROI_HEIGHT_CM / 100.0) / 2.0
  104. half_width_px = int(_depth_intrinsics.fx * half_width_m / center_distance_m)
  105. half_height_px = int(_depth_intrinsics.fy * half_height_m / center_distance_m)
  106. if half_width_px <= 0 or half_height_px <= 0:
  107. return None
  108. half_width_px = min(half_width_px, center_x, width - center_x - 1)
  109. half_height_px = min(half_height_px, center_y, height - center_y - 1)
  110. if half_width_px <= 0 or half_height_px <= 0:
  111. return None
  112. x_start = center_x - half_width_px
  113. x_end = center_x + half_width_px + 1
  114. y_start = center_y - half_height_px
  115. y_end = center_y + half_height_px + 1
  116. roi = depth_data[y_start:y_end, x_start:x_end]
  117. valid_values = roi[(roi >= MIN_DEPTH) & (roi <= MAX_DEPTH)]
  118. if valid_values.size == 0:
  119. return None
  120. if NEAREST_PERCENTILE and 0 < NEAREST_PERCENTILE < 100:
  121. return int(np.percentile(valid_values, NEAREST_PERCENTILE))
  122. return int(valid_values.min())
  123. @app.on_event("startup")
  124. def on_startup():
  125. _init_camera()
  126. @app.on_event("shutdown")
  127. def on_shutdown():
  128. _shutdown_camera()
  129. @app.get("/height")
  130. def get_height():
  131. start_time = time.time()
  132. samples = []
  133. with _lock:
  134. while len(samples) < SAMPLE_COUNT and (time.time() - start_time) < SAMPLE_TIMEOUT_SEC:
  135. value = _measure_once()
  136. if value is not None:
  137. samples.append(value)
  138. if len(samples) < SAMPLE_COUNT:
  139. raise HTTPException(status_code=503, detail="Insufficient valid samples from depth camera")
  140. median_value = int(np.median(np.array(samples, dtype=np.int32)))
  141. return {
  142. "height_mm": median_value,
  143. "samples": samples,
  144. "unit": "mm",
  145. "sample_count": SAMPLE_COUNT,
  146. }
  147. @app.get("/health")
  148. def health():
  149. return {"status": "ok"}