percepto 0.1.dev40__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- percepto/__init__.py +131 -0
- percepto/_compose.py +68 -0
- percepto/_hardware.py +35 -0
- percepto/_registry.py +71 -0
- percepto/_resolve.py +120 -0
- percepto/_router.py +44 -0
- percepto/_skill.py +91 -0
- percepto/_version.py +34 -0
- percepto/cli/__init__.py +191 -0
- percepto/cli/_commands/__init__.py +0 -0
- percepto/cli/_commands/detect/__init__.py +0 -0
- percepto/cli/_commands/detect/analyze_cmd.py +300 -0
- percepto/cli/_commands/detect/benchmark_cmd.py +175 -0
- percepto/cli/_commands/detect/blur_cmd.py +183 -0
- percepto/cli/_commands/detect/compare_cmd.py +112 -0
- percepto/cli/_commands/detect/dashboard_cmd.py +142 -0
- percepto/cli/_commands/detect/demo_cmd.py +78 -0
- percepto/cli/_commands/detect/detect_cmd.py +415 -0
- percepto/cli/_commands/detect/evaluate_cmd.py +387 -0
- percepto/cli/_commands/detect/export_cmd.py +189 -0
- percepto/cli/_commands/detect/finetune_cmd.py +284 -0
- percepto/cli/_commands/detect/info_cmd.py +117 -0
- percepto/cli/_commands/detect/label_cmd.py +241 -0
- percepto/cli/_commands/detect/models_cmd.py +103 -0
- percepto/cli/_commands/detect/prune_cmd.py +120 -0
- percepto/cli/_commands/detect/serve_cmd.py +66 -0
- percepto/cli/_commands/detect/track_cmd.py +153 -0
- percepto/cli/_commands/detect/train_cmd.py +201 -0
- percepto/cli/_commands/detect/tune_cmd.py +183 -0
- percepto/cli/_commands/doctor_cmd.py +140 -0
- percepto/cli/_commands/mcp_install_cmd.py +90 -0
- percepto/evaluation/__init__.py +1 -0
- percepto/evaluation/coco_json.py +89 -0
- percepto/evaluation/coco_map.py +260 -0
- percepto/evaluation/confusion.py +192 -0
- percepto/export/__init__.py +7 -0
- percepto/export/coreml.py +72 -0
- percepto/export/onnx.py +81 -0
- percepto/export/openvino.py +47 -0
- percepto/export/quantize.py +127 -0
- percepto/export/tensorrt.py +90 -0
- percepto/export/tflite.py +112 -0
- percepto/hub/__init__.py +1 -0
- percepto/hub/download.py +82 -0
- percepto/hub/hub.py +260 -0
- percepto/mcp/__init__.py +19 -0
- percepto/mcp/server.py +74 -0
- percepto/nn/__init__.py +6 -0
- percepto/nn/attention.py +24 -0
- percepto/nn/conv.py +88 -0
- percepto/serving/__init__.py +1 -0
- percepto/serving/app.py +103 -0
- percepto/skills/__init__.py +0 -0
- percepto/skills/detect/__init__.py +147 -0
- percepto/skills/detect/_cli_utils.py +128 -0
- percepto/skills/detect/inference/__init__.py +5 -0
- percepto/skills/detect/inference/annotate.py +115 -0
- percepto/skills/detect/inference/detect.py +330 -0
- percepto/skills/detect/inference/onnx_detector.py +175 -0
- percepto/skills/detect/inference/postprocess.py +98 -0
- percepto/skills/detect/inference/preprocess.py +123 -0
- percepto/skills/detect/inference/visualize.py +139 -0
- percepto/skills/detect/models/__init__.py +23 -0
- percepto/skills/detect/models/_model_registry.py +75 -0
- percepto/skills/detect/models/dfine/__init__.py +44 -0
- percepto/skills/detect/models/dfine/backbone.py +63 -0
- percepto/skills/detect/models/dfine/config.py +82 -0
- percepto/skills/detect/models/dfine/decoder.py +69 -0
- percepto/skills/detect/models/dfine/encoder.py +102 -0
- percepto/skills/detect/models/dfine/head.py +80 -0
- percepto/skills/detect/models/dfine/model.py +88 -0
- percepto/skills/detect/models/rtmdet/__init__.py +67 -0
- percepto/skills/detect/models/rtmdet/backbone.py +76 -0
- percepto/skills/detect/models/rtmdet/blocks.py +83 -0
- percepto/skills/detect/models/rtmdet/config.py +112 -0
- percepto/skills/detect/models/rtmdet/head.py +196 -0
- percepto/skills/detect/models/rtmdet/model.py +45 -0
- percepto/skills/detect/models/rtmdet/neck.py +122 -0
- percepto/skills/detect/models/yolonas/__init__.py +47 -0
- percepto/skills/detect/models/yolonas/backbone.py +88 -0
- percepto/skills/detect/models/yolonas/blocks.py +595 -0
- percepto/skills/detect/models/yolonas/config.py +172 -0
- percepto/skills/detect/models/yolonas/head.py +282 -0
- percepto/skills/detect/models/yolonas/model.py +75 -0
- percepto/skills/detect/models/yolonas/neck.py +234 -0
- percepto/skills/detect/models/yolov9/__init__.py +57 -0
- percepto/skills/detect/models/yolov9/backbone.py +67 -0
- percepto/skills/detect/models/yolov9/blocks.py +217 -0
- percepto/skills/detect/models/yolov9/config.py +254 -0
- percepto/skills/detect/models/yolov9/head.py +228 -0
- percepto/skills/detect/models/yolov9/model.py +45 -0
- percepto/skills/detect/models/yolov9/neck.py +68 -0
- percepto/skills/detect/models/yoloworld/__init__.py +43 -0
- percepto/skills/detect/models/yoloworld/config.py +47 -0
- percepto/skills/detect/models/yoloworld/head.py +151 -0
- percepto/skills/detect/models/yoloworld/model.py +121 -0
- percepto/skills/detect/models/yoloworld/neck.py +188 -0
- percepto/skills/detect/models/yoloworld/text_encoder.py +94 -0
- percepto/skills/detect/models/yolox/__init__.py +57 -0
- percepto/skills/detect/models/yolox/backbone.py +91 -0
- percepto/skills/detect/models/yolox/blocks.py +75 -0
- percepto/skills/detect/models/yolox/config.py +63 -0
- percepto/skills/detect/models/yolox/head.py +209 -0
- percepto/skills/detect/models/yolox/model.py +45 -0
- percepto/skills/detect/models/yolox/neck.py +126 -0
- percepto/skills/detect/schemas.py +44 -0
- percepto/skills/detect/validation.py +56 -0
- percepto/tracking/__init__.py +83 -0
- percepto/tracking/byte_tracker.py +219 -0
- percepto/tracking/trackers_backend.py +143 -0
- percepto/training/__init__.py +37 -0
- percepto/training/assigners/__init__.py +3 -0
- percepto/training/assigners/tal.py +147 -0
- percepto/training/callbacks.py +360 -0
- percepto/training/config.py +99 -0
- percepto/training/datamodule.py +137 -0
- percepto/training/datasets/__init__.py +3 -0
- percepto/training/datasets/coco.py +165 -0
- percepto/training/datasets/mosaic.py +170 -0
- percepto/training/datasets/transforms.py +215 -0
- percepto/training/datasets/yolo.py +163 -0
- percepto/training/loggers.py +86 -0
- percepto/training/losses/__init__.py +3 -0
- percepto/training/losses/detection_loss.py +278 -0
- percepto/training/losses/dfl.py +50 -0
- percepto/training/losses/focal.py +102 -0
- percepto/training/losses/iou.py +82 -0
- percepto/training/metrics.py +127 -0
- percepto/training/module.py +189 -0
- percepto/training/qat.py +79 -0
- percepto-0.1.dev40.dist-info/METADATA +63 -0
- percepto-0.1.dev40.dist-info/RECORD +135 -0
- percepto-0.1.dev40.dist-info/WHEEL +4 -0
- percepto-0.1.dev40.dist-info/entry_points.txt +5 -0
- percepto-0.1.dev40.dist-info/licenses/LICENSE +201 -0
percepto/__init__.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""percepto — universal perception platform for AI agents.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
import percepto
|
|
6
|
+
|
|
7
|
+
# Quick inference (auto-routes to best skill)
|
|
8
|
+
result = percepto.run("detect", source="image.jpg")
|
|
9
|
+
|
|
10
|
+
# Load a specific model
|
|
11
|
+
model = percepto.load("rtmdet-s", pretrained=True)
|
|
12
|
+
|
|
13
|
+
# Natural language routing
|
|
14
|
+
skill = percepto.understand("find objects in this image")
|
|
15
|
+
|
|
16
|
+
# List all capabilities
|
|
17
|
+
percepto.capabilities()
|
|
18
|
+
|
|
19
|
+
# High-level inference (direct)
|
|
20
|
+
det = percepto.Detector("rtmdet-s")
|
|
21
|
+
result = det("image.jpg")
|
|
22
|
+
result.save("output.jpg")
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
# Register built-in skills
|
|
26
|
+
from percepto._registry import register as _register
|
|
27
|
+
from percepto._version import __version__
|
|
28
|
+
from percepto.skills.detect import DetectionSkill
|
|
29
|
+
|
|
30
|
+
_register(DetectionSkill())
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def run(skill_name: str, **kwargs):
|
|
34
|
+
"""Run a perception skill by name.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
skill_name: Skill name (e.g. 'detect').
|
|
38
|
+
**kwargs: Arguments passed to the skill's input schema.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Structured output (Pydantic model).
|
|
42
|
+
"""
|
|
43
|
+
from percepto._registry import get
|
|
44
|
+
|
|
45
|
+
skill = get(skill_name)
|
|
46
|
+
if not skill.is_loaded:
|
|
47
|
+
skill.load(
|
|
48
|
+
model=kwargs.pop("model", "auto"),
|
|
49
|
+
device=kwargs.pop("device", "auto"),
|
|
50
|
+
)
|
|
51
|
+
input_obj = skill.input_schema(**kwargs)
|
|
52
|
+
return skill.run(input_obj)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load(model_name: str, pretrained: bool = True, num_classes: int = 80, **kwargs):
|
|
56
|
+
"""Load a detection model by name.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
model_name: Model variant (e.g. 'rtmdet-s').
|
|
60
|
+
pretrained: Load pretrained weights.
|
|
61
|
+
num_classes: Number of classes.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
PyTorch nn.Module.
|
|
65
|
+
"""
|
|
66
|
+
from percepto.skills.detect.models import load as _load_model
|
|
67
|
+
|
|
68
|
+
return _load_model(model_name, pretrained=pretrained, num_classes=num_classes, **kwargs)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def available_models() -> list[str]:
|
|
72
|
+
"""Return sorted list of all available detection model names."""
|
|
73
|
+
from percepto.skills.detect.models import available_models as _available_models
|
|
74
|
+
|
|
75
|
+
return _available_models()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def understand(query: str):
|
|
79
|
+
"""Route a natural language query to the best skill.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
query: e.g. "find objects in this image"
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Best matching Skill instance, or None.
|
|
86
|
+
"""
|
|
87
|
+
from percepto._router import SkillRouter
|
|
88
|
+
|
|
89
|
+
router = SkillRouter()
|
|
90
|
+
return router.route(query)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def capabilities() -> dict[str, list[str]]:
|
|
94
|
+
"""List all skills and their capabilities.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Dict mapping skill names to their capability tags.
|
|
98
|
+
"""
|
|
99
|
+
from percepto._registry import all_skills
|
|
100
|
+
|
|
101
|
+
return {name: skill.capabilities for name, skill in all_skills().items()}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def compose(*steps):
|
|
105
|
+
"""Create a validated pipeline from skill steps.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
*steps: (skill_name, config_dict) tuples.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
Pipeline instance.
|
|
112
|
+
"""
|
|
113
|
+
from percepto._compose import compose as _compose
|
|
114
|
+
|
|
115
|
+
return _compose(*steps)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# Convenience re-exports for detection (most common use case)
|
|
119
|
+
from percepto.skills.detect.inference.detect import Detection, Detector
|
|
120
|
+
|
|
121
|
+
__all__ = [
|
|
122
|
+
"__version__",
|
|
123
|
+
"run",
|
|
124
|
+
"load",
|
|
125
|
+
"available_models",
|
|
126
|
+
"understand",
|
|
127
|
+
"capabilities",
|
|
128
|
+
"compose",
|
|
129
|
+
"Detection",
|
|
130
|
+
"Detector",
|
|
131
|
+
]
|
percepto/_compose.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Pipeline composition — chain skills together with build-time validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
from percepto._registry import get
|
|
10
|
+
from percepto._skill import Skill
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Pipeline:
|
|
14
|
+
"""A validated chain of skills that processes data sequentially."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, steps: list[tuple[str, dict[str, Any]]]) -> None:
|
|
17
|
+
"""Build and validate a pipeline.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
steps: List of (skill_name, config_kwargs) tuples.
|
|
21
|
+
|
|
22
|
+
Raises:
|
|
23
|
+
ValueError: If skills don't exist.
|
|
24
|
+
"""
|
|
25
|
+
self._steps: list[tuple[Skill, dict[str, Any]]] = []
|
|
26
|
+
|
|
27
|
+
for skill_name, kwargs in steps:
|
|
28
|
+
skill = get(skill_name)
|
|
29
|
+
self._steps.append((skill, kwargs))
|
|
30
|
+
|
|
31
|
+
self._validate_chain()
|
|
32
|
+
|
|
33
|
+
def _validate_chain(self) -> None:
|
|
34
|
+
"""Validate the pipeline chain.
|
|
35
|
+
|
|
36
|
+
For Phase 3.0 with only detection, this is a basic check.
|
|
37
|
+
Full type compatibility validation comes when more skills are added.
|
|
38
|
+
"""
|
|
39
|
+
if not self._steps:
|
|
40
|
+
msg = "Pipeline must have at least one step."
|
|
41
|
+
raise ValueError(msg)
|
|
42
|
+
|
|
43
|
+
def run(self, initial_input: BaseModel) -> BaseModel:
|
|
44
|
+
"""Execute all steps sequentially."""
|
|
45
|
+
result = initial_input
|
|
46
|
+
for skill, kwargs in self._steps:
|
|
47
|
+
if not skill.is_loaded:
|
|
48
|
+
skill.load(**kwargs)
|
|
49
|
+
result = skill.run(result)
|
|
50
|
+
return result
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def steps(self) -> list[str]:
|
|
54
|
+
"""Return skill names in order."""
|
|
55
|
+
return [skill.name for skill, _ in self._steps]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def compose(*steps: tuple[str, dict[str, Any]]) -> Pipeline:
|
|
59
|
+
"""Create a validated Pipeline from skill steps.
|
|
60
|
+
|
|
61
|
+
Usage::
|
|
62
|
+
|
|
63
|
+
pipe = compose(
|
|
64
|
+
("detect", {"model": "rtmdet-l"}),
|
|
65
|
+
)
|
|
66
|
+
result = pipe.run(DetectionInput(source="image.jpg"))
|
|
67
|
+
"""
|
|
68
|
+
return Pipeline(list(steps))
|
percepto/_hardware.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Hardware detection and model auto-selection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def resolve_auto_model(device: str = "auto") -> str:
|
|
7
|
+
"""Pick a detection model based on available hardware.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
device: Target device string or 'auto'.
|
|
11
|
+
|
|
12
|
+
Returns:
|
|
13
|
+
Model variant name (e.g. 'rtmdet-l', 'rtmdet-s', 'rtmdet-tiny').
|
|
14
|
+
"""
|
|
15
|
+
import torch
|
|
16
|
+
from loguru import logger
|
|
17
|
+
|
|
18
|
+
has_cuda = torch.cuda.is_available() if device == "auto" else device.startswith("cuda")
|
|
19
|
+
|
|
20
|
+
if has_cuda:
|
|
21
|
+
try:
|
|
22
|
+
vram_mb = torch.cuda.get_device_properties(0).total_mem / (1024 * 1024)
|
|
23
|
+
if vram_mb >= 6000:
|
|
24
|
+
logger.debug("Auto model: cuda=True, vram={:.0f}MB -> rtmdet-l", vram_mb)
|
|
25
|
+
return "rtmdet-l"
|
|
26
|
+
if vram_mb >= 3000:
|
|
27
|
+
logger.debug("Auto model: cuda=True, vram={:.0f}MB -> rtmdet-s", vram_mb)
|
|
28
|
+
return "rtmdet-s"
|
|
29
|
+
except Exception:
|
|
30
|
+
pass
|
|
31
|
+
logger.debug("Auto model: cuda=True, vram=unknown -> rtmdet-s")
|
|
32
|
+
return "rtmdet-s"
|
|
33
|
+
|
|
34
|
+
logger.debug("Auto model: cuda=False -> rtmdet-tiny")
|
|
35
|
+
return "rtmdet-tiny"
|
percepto/_registry.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Skill registry — discover and manage perception skills."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import entry_points
|
|
6
|
+
|
|
7
|
+
from percepto._skill import Skill
|
|
8
|
+
|
|
9
|
+
# Global skill registry
|
|
10
|
+
_SKILLS: dict[str, Skill] = {}
|
|
11
|
+
_discovered = False
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def register(skill: Skill) -> None:
|
|
15
|
+
"""Explicitly register a skill instance."""
|
|
16
|
+
if not isinstance(skill, Skill):
|
|
17
|
+
msg = f"Expected a Skill, got {type(skill).__name__}"
|
|
18
|
+
raise TypeError(msg)
|
|
19
|
+
if skill.name in _SKILLS:
|
|
20
|
+
msg = f"Skill {skill.name!r} is already registered."
|
|
21
|
+
raise ValueError(msg)
|
|
22
|
+
_SKILLS[skill.name] = skill
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get(name: str) -> Skill:
|
|
26
|
+
"""Get a registered skill by name."""
|
|
27
|
+
_discover_entry_points()
|
|
28
|
+
if name not in _SKILLS:
|
|
29
|
+
available = sorted(_SKILLS.keys())
|
|
30
|
+
msg = f"Unknown skill: {name!r}. Available: {available}"
|
|
31
|
+
raise ValueError(msg)
|
|
32
|
+
return _SKILLS[name]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def available_skills() -> list[str]:
|
|
36
|
+
"""Return sorted list of registered skill names."""
|
|
37
|
+
_discover_entry_points()
|
|
38
|
+
return sorted(_SKILLS.keys())
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def all_skills() -> dict[str, Skill]:
|
|
42
|
+
"""Return all registered skills."""
|
|
43
|
+
_discover_entry_points()
|
|
44
|
+
return dict(_SKILLS)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _discover_entry_points() -> None:
|
|
48
|
+
"""Discover skills from 'percepto.skills' entry point group (lazy, once)."""
|
|
49
|
+
global _discovered # noqa: PLW0603
|
|
50
|
+
if _discovered:
|
|
51
|
+
return
|
|
52
|
+
_discovered = True
|
|
53
|
+
|
|
54
|
+
eps = entry_points(group="percepto.skills")
|
|
55
|
+
for ep in eps:
|
|
56
|
+
if ep.name not in _SKILLS:
|
|
57
|
+
try:
|
|
58
|
+
skill_factory = ep.load()
|
|
59
|
+
skill = skill_factory()
|
|
60
|
+
register(skill)
|
|
61
|
+
except Exception:
|
|
62
|
+
from loguru import logger
|
|
63
|
+
|
|
64
|
+
logger.warning("Failed to load skill from entry point {!r} — skipping", ep.name)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _reset() -> None:
|
|
68
|
+
"""Reset registry state (for testing only)."""
|
|
69
|
+
global _discovered # noqa: PLW0603
|
|
70
|
+
_SKILLS.clear()
|
|
71
|
+
_discovered = False
|
percepto/_resolve.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Source resolution — handle file paths, URLs, and base64 image inputs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import ipaddress
|
|
7
|
+
import re
|
|
8
|
+
import socket
|
|
9
|
+
import tempfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from urllib.parse import urlparse
|
|
12
|
+
|
|
13
|
+
from loguru import logger
|
|
14
|
+
|
|
15
|
+
_BASE64_PATTERN = re.compile(r"^[A-Za-z0-9+/=]{100,}$")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def resolve_source(source: str) -> Path:
|
|
19
|
+
"""Resolve an image source to a local file path.
|
|
20
|
+
|
|
21
|
+
Handles three input types:
|
|
22
|
+
- File path: returned as-is (validated to exist)
|
|
23
|
+
- URL (http/https): downloaded to temp file with SSRF protection
|
|
24
|
+
- Base64: decoded to temp file
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
source: File path, URL, or base64-encoded image string.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Path to the resolved image file.
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
FileNotFoundError: If source is a file path that doesn't exist.
|
|
34
|
+
ValueError: If source is an invalid URL, blocked by SSRF protection,
|
|
35
|
+
or invalid base64.
|
|
36
|
+
ConnectionError: If URL fetch fails.
|
|
37
|
+
"""
|
|
38
|
+
# Check for data URI (data:image/png;base64,...)
|
|
39
|
+
if source.startswith("data:image/"):
|
|
40
|
+
return _decode_base64(source.split(",", 1)[1] if "," in source else source)
|
|
41
|
+
|
|
42
|
+
# Check for URL
|
|
43
|
+
if source.startswith(("http://", "https://")):
|
|
44
|
+
return _fetch_url(source)
|
|
45
|
+
|
|
46
|
+
# Check for raw base64 string
|
|
47
|
+
if _BASE64_PATTERN.match(source):
|
|
48
|
+
return _decode_base64(source)
|
|
49
|
+
|
|
50
|
+
# Treat as file path
|
|
51
|
+
path = Path(source)
|
|
52
|
+
if not path.exists():
|
|
53
|
+
msg = f"Image file not found: {source}"
|
|
54
|
+
raise FileNotFoundError(msg)
|
|
55
|
+
return path
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _decode_base64(data: str) -> Path:
|
|
59
|
+
"""Decode base64 image data to a temporary file."""
|
|
60
|
+
try:
|
|
61
|
+
raw = base64.b64decode(data)
|
|
62
|
+
except Exception as e:
|
|
63
|
+
msg = f"Invalid base64 image data: {e}"
|
|
64
|
+
raise ValueError(msg) from e
|
|
65
|
+
|
|
66
|
+
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
|
|
67
|
+
tmp.write(raw)
|
|
68
|
+
tmp.close()
|
|
69
|
+
logger.debug("Decoded base64 image to {}", tmp.name)
|
|
70
|
+
return Path(tmp.name)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _fetch_url(url: str) -> Path:
|
|
74
|
+
"""Download image from URL with SSRF protection."""
|
|
75
|
+
parsed = urlparse(url)
|
|
76
|
+
hostname = parsed.hostname
|
|
77
|
+
|
|
78
|
+
if not hostname:
|
|
79
|
+
msg = f"Invalid URL (no hostname): {url}"
|
|
80
|
+
raise ValueError(msg)
|
|
81
|
+
|
|
82
|
+
# Resolve hostname to IP and validate against SSRF
|
|
83
|
+
_validate_hostname(hostname)
|
|
84
|
+
|
|
85
|
+
# Download
|
|
86
|
+
try:
|
|
87
|
+
import urllib.request
|
|
88
|
+
|
|
89
|
+
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
|
|
90
|
+
tmp.close()
|
|
91
|
+
urllib.request.urlretrieve(url, tmp.name) # noqa: S310
|
|
92
|
+
logger.debug("Downloaded image from {} to {}", url, tmp.name)
|
|
93
|
+
return Path(tmp.name)
|
|
94
|
+
except Exception as e:
|
|
95
|
+
msg = f"Failed to download image from {url}: {e}"
|
|
96
|
+
raise ConnectionError(msg) from e
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _validate_hostname(hostname: str) -> None:
|
|
100
|
+
"""Validate hostname is not a private/reserved IP (SSRF protection).
|
|
101
|
+
|
|
102
|
+
Resolves the hostname via DNS and checks the resolved IP against
|
|
103
|
+
blocked ranges: private (RFC 1918), link-local, loopback, reserved.
|
|
104
|
+
"""
|
|
105
|
+
try:
|
|
106
|
+
results = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
|
|
107
|
+
except socket.gaierror as e:
|
|
108
|
+
msg = f"Cannot resolve hostname: {hostname}"
|
|
109
|
+
raise ValueError(msg) from e
|
|
110
|
+
|
|
111
|
+
for family, _, _, _, sockaddr in results:
|
|
112
|
+
ip_str = sockaddr[0]
|
|
113
|
+
try:
|
|
114
|
+
ip = ipaddress.ip_address(ip_str)
|
|
115
|
+
except ValueError:
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
|
|
119
|
+
msg = f"URL blocked: {hostname} resolves to private/reserved IP {ip_str}"
|
|
120
|
+
raise ValueError(msg)
|
percepto/_router.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Smart Skill Router — maps natural language queries to skills."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from percepto._registry import all_skills
|
|
6
|
+
from percepto._skill import Skill
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SkillRouter:
|
|
10
|
+
"""Routes queries to the best matching skill using keyword matching.
|
|
11
|
+
|
|
12
|
+
Phase 1: keyword overlap scoring.
|
|
13
|
+
Future: swap in embedding-based matching without changing the interface.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def route(self, query: str) -> Skill | None:
|
|
17
|
+
"""Find the best skill for a natural-language query.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
query: e.g. "find objects in this image", "detect cars"
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
Best matching Skill, or None if no match above threshold.
|
|
24
|
+
"""
|
|
25
|
+
skills = all_skills()
|
|
26
|
+
if not skills:
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
query_tokens = set(query.lower().split())
|
|
30
|
+
best_skill = None
|
|
31
|
+
best_score = 0
|
|
32
|
+
|
|
33
|
+
for skill in skills.values():
|
|
34
|
+
caps = set(skill.capabilities)
|
|
35
|
+
# Also include the skill name and words from description
|
|
36
|
+
caps.add(skill.name)
|
|
37
|
+
caps.update(skill.description.lower().split())
|
|
38
|
+
|
|
39
|
+
score = len(query_tokens & caps)
|
|
40
|
+
if score > best_score:
|
|
41
|
+
best_score = score
|
|
42
|
+
best_skill = skill
|
|
43
|
+
|
|
44
|
+
return best_skill if best_score > 0 else None
|
percepto/_skill.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Skill Protocol — the contract every perception skill implements."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@runtime_checkable
|
|
11
|
+
class Skill(Protocol):
|
|
12
|
+
"""A perception skill that can be discovered, invoked, and composed.
|
|
13
|
+
|
|
14
|
+
Every skill declares:
|
|
15
|
+
- What it does (metadata for CLI/MCP auto-generation)
|
|
16
|
+
- What inputs it accepts (Pydantic schema)
|
|
17
|
+
- What outputs it produces (Pydantic schema)
|
|
18
|
+
- How to run it
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def name(self) -> str:
|
|
23
|
+
"""Unique skill identifier, e.g. 'detect', 'segment', 'ocr'."""
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def description(self) -> str:
|
|
28
|
+
"""Human-readable description for CLI help and MCP tool descriptions."""
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def version(self) -> str:
|
|
33
|
+
"""Skill version string."""
|
|
34
|
+
...
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def input_schema(self) -> type[BaseModel]:
|
|
38
|
+
"""Pydantic model class describing accepted inputs."""
|
|
39
|
+
...
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def output_schema(self) -> type[BaseModel]:
|
|
43
|
+
"""Pydantic model class describing outputs."""
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def capabilities(self) -> list[str]:
|
|
48
|
+
"""Tags describing what this skill can do. Used by the router.
|
|
49
|
+
|
|
50
|
+
Examples: ['detect', 'objects', 'bounding-boxes', 'coco']
|
|
51
|
+
"""
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
def load(self, model: str = "auto", device: str = "auto", **kwargs: Any) -> None:
|
|
55
|
+
"""Load and prepare the underlying model. Called before run().
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
model: Model variant name or 'auto' for hardware-aware selection.
|
|
59
|
+
device: Target device.
|
|
60
|
+
**kwargs: Skill-specific configuration.
|
|
61
|
+
"""
|
|
62
|
+
...
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def is_loaded(self) -> bool:
|
|
66
|
+
"""Whether the skill's model is loaded and ready."""
|
|
67
|
+
...
|
|
68
|
+
|
|
69
|
+
def run(self, input: BaseModel) -> BaseModel:
|
|
70
|
+
"""Execute the skill on validated input, returning structured output.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
input: Validated input matching self.input_schema.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Result matching self.output_schema.
|
|
77
|
+
"""
|
|
78
|
+
...
|
|
79
|
+
|
|
80
|
+
def available_models(self) -> list[str]:
|
|
81
|
+
"""List model variants this skill supports."""
|
|
82
|
+
...
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def cli_commands(self) -> dict[str, Any] | None:
|
|
86
|
+
"""Optional: extra CLI commands this skill provides beyond run().
|
|
87
|
+
|
|
88
|
+
Returns a dict mapping command names to typer-compatible functions.
|
|
89
|
+
If None, the CLI auto-generates a single command from run().
|
|
90
|
+
"""
|
|
91
|
+
...
|
percepto/_version.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# file generated by setuptools-scm
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"__version__",
|
|
6
|
+
"__version_tuple__",
|
|
7
|
+
"version",
|
|
8
|
+
"version_tuple",
|
|
9
|
+
"__commit_id__",
|
|
10
|
+
"commit_id",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
TYPE_CHECKING = False
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from typing import Tuple
|
|
16
|
+
from typing import Union
|
|
17
|
+
|
|
18
|
+
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
19
|
+
COMMIT_ID = Union[str, None]
|
|
20
|
+
else:
|
|
21
|
+
VERSION_TUPLE = object
|
|
22
|
+
COMMIT_ID = object
|
|
23
|
+
|
|
24
|
+
version: str
|
|
25
|
+
__version__: str
|
|
26
|
+
__version_tuple__: VERSION_TUPLE
|
|
27
|
+
version_tuple: VERSION_TUPLE
|
|
28
|
+
commit_id: COMMIT_ID
|
|
29
|
+
__commit_id__: COMMIT_ID
|
|
30
|
+
|
|
31
|
+
__version__ = version = '0.1.dev40'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 1, 'dev40')
|
|
33
|
+
|
|
34
|
+
__commit_id__ = commit_id = None
|