-
Notifications
You must be signed in to change notification settings - Fork 788
Add offline object inventory and localize perception pipeline DIM1343 #3422
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
54f0000
c54ae9a
122ff2d
d08cf7e
0da7384
32c8e9e
b3b0b00
13bf277
58b76a8
c69a4a5
216ffed
a428723
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| # Copyright 2026 Dimensional Inc. | ||
| # | ||
| # Licensed under the Apache License, Version 2.0 (the "License"); | ||
| # you may not use this file except in compliance with the License. | ||
| # You may obtain a copy of the License at | ||
| # | ||
| # http://www.apache.org/licenses/LICENSE-2.0 | ||
| # | ||
| # Unless required by applicable law or agreed to in writing, software | ||
| # distributed under the License is distributed on an "AS IS" BASIS, | ||
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| # See the License for the specific language governing permissions and | ||
| # limitations under the License. | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from functools import cached_property | ||
| from typing import overload | ||
|
|
||
| from PIL import Image as PILImage | ||
| import torch | ||
| import torch.nn.functional as functional | ||
| from transformers import SiglipModel as HFSiglipModel, SiglipProcessor | ||
|
|
||
| from dimos.models.base import HuggingFaceModel | ||
| from dimos.models.embedding.base import Embedding, EmbeddingModel, HuggingFaceEmbeddingModelConfig | ||
| from dimos.msgs.sensor_msgs.Image import Image | ||
|
|
||
|
|
||
| class SigLIPModelConfig(HuggingFaceEmbeddingModelConfig): | ||
| model_name: str = "google/siglip-base-patch16-224" | ||
| dtype: torch.dtype = torch.float32 | ||
|
|
||
|
|
||
| class SigLIPModel(EmbeddingModel, HuggingFaceModel): | ||
| """SigLIP image/text embedding model for frame-level semantic retrieval. | ||
|
|
||
| Weights come from the Hugging Face hub cache: a miss downloads once, a | ||
| hit reuses the cache. Text inputs use ``padding="max_length"`` - SigLIP | ||
| was trained with max-length padded text, and unpadded prompts measurably | ||
| degrade the text-image alignment. | ||
| """ | ||
|
|
||
| config: SigLIPModelConfig | ||
| _model_class = HFSiglipModel | ||
|
|
||
| @cached_property | ||
| def _model(self) -> HFSiglipModel: | ||
| self._ensure_cuda_initialized() | ||
| return HFSiglipModel.from_pretrained(self.config.model_name).eval().to(self.config.device) | ||
|
|
||
| @cached_property | ||
| def _processor(self) -> SiglipProcessor: | ||
| return SiglipProcessor.from_pretrained(self.config.model_name, use_fast=True) | ||
|
|
||
| @property | ||
| def logit_scale(self) -> float: | ||
| """Trained sigmoid temperature: exp of the model's raw logit scale.""" | ||
| return float(self._model.logit_scale.exp()) | ||
|
|
||
| @overload | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. from what i recall @paul-nechifor complains about this. i guess it's nice because it keeps the error checking in load time rather than run time if you use *kwargs. no strong preferences from me tho as a first pass.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am with you on this one. Let's wait for @paul-nechifor to complain, but that will be 2 vs 1 :). |
||
| def embed(self, image: Image, /) -> Embedding: ... | ||
| @overload | ||
| def embed(self, *images: Image) -> list[Embedding]: ... | ||
| def embed(self, *images: Image) -> Embedding | list[Embedding]: | ||
| """Embed one or more images into the shared image-text space.""" | ||
| pil_images = [PILImage.fromarray(img.to_rgb().data) for img in images] | ||
|
|
||
| with torch.inference_mode(): | ||
| inputs = self._processor(images=pil_images, return_tensors="pt").to(self.config.device) | ||
| image_features = self._model.get_image_features(**inputs) | ||
| if self.config.normalize: | ||
| image_features = functional.normalize(image_features, dim=-1) | ||
|
|
||
| embeddings = [ | ||
| Embedding(vector=feat, timestamp=images[i].ts) for i, feat in enumerate(image_features) | ||
| ] | ||
| return embeddings[0] if len(images) == 1 else embeddings | ||
|
|
||
| @overload | ||
| def embed_text(self, text: str, /) -> Embedding: ... | ||
| @overload | ||
| def embed_text(self, *texts: str) -> list[Embedding]: ... | ||
| def embed_text(self, *texts: str) -> Embedding | list[Embedding]: | ||
| """Embed one or more text strings into the shared image-text space.""" | ||
| with torch.inference_mode(): | ||
| inputs = self._processor( | ||
| text=list(texts), return_tensors="pt", padding="max_length", truncation=True | ||
| ).to(self.config.device) | ||
| text_features = self._model.get_text_features(**inputs) | ||
| if self.config.normalize: | ||
| text_features = functional.normalize(text_features, dim=-1) | ||
|
|
||
| embeddings = [Embedding(vector=feat) for feat in text_features] | ||
| return embeddings[0] if len(texts) == 1 else embeddings | ||
|
|
||
| def stop(self) -> None: | ||
| """Release model and free GPU memory.""" | ||
| if "_processor" in self.__dict__: | ||
| del self.__dict__["_processor"] | ||
| super().stop() | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,7 +13,8 @@ | |
| # limitations under the License. | ||
|
|
||
| from collections.abc import Generator | ||
| from contextlib import contextmanager | ||
| from contextlib import contextmanager, nullcontext | ||
| from functools import lru_cache | ||
| from pathlib import Path | ||
| import shutil | ||
| import tempfile | ||
|
|
@@ -26,6 +27,7 @@ | |
| from PIL import Image as PILImage | ||
| import torch | ||
|
|
||
| from dimos.models.base import default_torch_device | ||
| from dimos.msgs.sensor_msgs.Image import Image | ||
| from dimos.perception.detection.detectors.base import Detector | ||
| from dimos.perception.detection.type.detection2d.bbox import Detection2DBBox | ||
|
|
@@ -40,6 +42,20 @@ | |
|
|
||
| logger = setup_logger() | ||
|
|
||
| # Hole/sprinkle cleanup in SAM2 needs the optional CUDA extension sam2._C. | ||
| # Requesting it without _C triggers a per-call UserWarning and then skips. | ||
| _FILL_HOLE_AREA = 8 | ||
| _MIN_MASK_REGION_AREA = 150 | ||
|
|
||
|
|
||
| @lru_cache(maxsize=1) | ||
| def _sam2_cuda_postprocess_available() -> bool: | ||
| try: | ||
| from sam2 import _C # noqa: F401 | ||
| except ImportError: | ||
| return False | ||
| return True | ||
|
|
||
|
|
||
| class SAM2InferenceState(TypedDict): | ||
| images: list[torch.Tensor | None] | ||
|
|
@@ -54,17 +70,19 @@ def _build_model() -> "SAM2VideoPredictor": | |
| if not local_config_path.exists(): | ||
| raise FileNotFoundError(f"EdgeTAM config not found at {local_config_path}") | ||
|
|
||
| if not torch.cuda.is_available(): | ||
| raise RuntimeError("EdgeTAM requires a CUDA-capable GPU") | ||
| device = default_torch_device() | ||
| if device == "cpu": | ||
| raise RuntimeError("EdgeTAM requires a CUDA or MPS device") | ||
|
|
||
| cfg = OmegaConf.load(local_config_path) | ||
|
|
||
| fill_hole_area = _FILL_HOLE_AREA if _sam2_cuda_postprocess_available() else 0 | ||
| overrides = { | ||
| "model.sam_mask_decoder_extra_args.dynamic_multimask_via_stability": True, | ||
| "model.sam_mask_decoder_extra_args.dynamic_multimask_stability_delta": 0.05, | ||
| "model.sam_mask_decoder_extra_args.dynamic_multimask_stability_thresh": 0.98, | ||
| "model.binarize_mask_from_pts_for_mem_enc": True, | ||
| "model.fill_hole_area": 8, | ||
| "model.fill_hole_area": fill_hole_area, | ||
| } | ||
|
|
||
| for key, value in overrides.items(): | ||
|
|
@@ -90,7 +108,7 @@ def _build_model() -> "SAM2VideoPredictor": | |
| if unexpected_keys: | ||
| raise RuntimeError("Unexpected keys in checkpoint") | ||
|
|
||
| predictor = predictor.to("cuda") | ||
| predictor = predictor.to(device) | ||
| predictor.eval() | ||
| return predictor | ||
|
|
||
|
|
@@ -102,6 +120,59 @@ def __init__(self) -> None: | |
| from sam2.sam2_image_predictor import SAM2ImagePredictor | ||
|
|
||
| self._predictor = SAM2ImagePredictor(_build_model()) | ||
| self._mask_generators: dict[int, Any] = {} | ||
|
|
||
| def propose_all( | ||
| self, | ||
| image: Image, | ||
| points_per_side: int = 24, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. not sure where this is called by confirm these params are exposed somewhere high level for easy tuning
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These params are passed to class SAM2AutomaticMaskGenerator and SAM2AutomaticMaskGenerator is not a DimOS class. It is in the edgetam-dimos package, sam2.automatic_mask_generator. Basically out of our controll and has not optimal default params for us. It was meant to be changed by the caller
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. About "for easy tuning". Yeah, we can do this and put them directly into inventory() as a dedicated |
||
| pred_iou_thresh: float = 0.55, | ||
| stability_score_thresh: float = 0.85, | ||
| min_mask_region_area: int = _MIN_MASK_REGION_AREA, | ||
| ) -> ImageDetections2D: | ||
| """Class-agnostic mask proposals over the whole image, no vocabulary. | ||
|
|
||
| Wraps ``SAM2AutomaticMaskGenerator`` on the shared EdgeTAM model. | ||
| Proposals carry no names ("proposal") and ``confidence`` is the | ||
| predicted mask IoU. | ||
| """ | ||
| import cv2 | ||
| from sam2.automatic_mask_generator import SAM2AutomaticMaskGenerator | ||
|
|
||
| if not _sam2_cuda_postprocess_available(): | ||
| min_mask_region_area = 0 | ||
|
|
||
| generator = self._mask_generators.get(points_per_side) | ||
| if generator is None: | ||
| generator = SAM2AutomaticMaskGenerator( | ||
| model=self._predictor.model, | ||
| points_per_side=points_per_side, | ||
| pred_iou_thresh=pred_iou_thresh, | ||
| stability_score_thresh=stability_score_thresh, | ||
| min_mask_region_area=min_mask_region_area, | ||
| ) | ||
| self._mask_generators[points_per_side] = generator | ||
|
|
||
| rgb = cv2.cvtColor(image.to_opencv(), cv2.COLOR_BGR2RGB) | ||
| amp = ( | ||
| torch.autocast("cuda", dtype=torch.bfloat16) | ||
| if self._predictor.device.type == "cuda" | ||
| else nullcontext() | ||
| ) | ||
| with torch.no_grad(), amp: | ||
| proposals = generator.generate(rgb) | ||
|
|
||
| detections: list[Detection2DBBox] = [ | ||
| Detection2DSeg.from_sam2_result( | ||
| proposal["segmentation"], | ||
| i, | ||
| image, | ||
| name="proposal", | ||
| confidence=float(proposal.get("predicted_iou", 1.0)), | ||
| ) | ||
| for i, proposal in enumerate(proposals) | ||
| ] | ||
| return ImageDetections2D(image, [det for det in detections if det.is_valid()]) | ||
|
|
||
| def segment(self, detections: ImageDetections2D) -> ImageDetections2D: | ||
| """Refine box detections into mask detections (Detection2DSeg).""" | ||
|
|
@@ -113,7 +184,13 @@ def segment(self, detections: ImageDetections2D) -> ImageDetections2D: | |
| image = detections.image | ||
| rgb = cv2.cvtColor(image.to_opencv(), cv2.COLOR_BGR2RGB) | ||
|
|
||
| with torch.no_grad(), torch.autocast("cuda", dtype=torch.bfloat16): | ||
| # bfloat16 autocast is CUDA-only; MPS runs in float32 | ||
| amp = ( | ||
| torch.autocast("cuda", dtype=torch.bfloat16) | ||
| if self._predictor.device.type == "cuda" | ||
| else nullcontext() | ||
| ) | ||
| with torch.no_grad(), amp: | ||
| self._predictor.set_image(rgb) | ||
| boxes = np.array([det.bbox for det in detections], dtype=np.float32) | ||
| masks, _, _ = self._predictor.predict(box=boxes, multimask_output=False) | ||
|
|
@@ -168,7 +245,7 @@ def _prepare_frame(self, image: Image) -> torch.Tensor: | |
| img_np /= img_std | ||
|
|
||
| img_tensor = torch.from_numpy(img_np).permute(2, 0, 1).float() | ||
| img_tensor = img_tensor.cuda() | ||
| img_tensor = img_tensor.to(self._predictor.device) | ||
|
|
||
| return img_tensor | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nitpick this is references in other files as a util so just move to a model utils folder
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Idk on this one, we need need to create dimos/models/utils for this 6-line probe and the import back into base.py