diff --git a/core/api/detect/config.py b/core/api/detect/config.py index b41de84..e2fa122 100644 --- a/core/api/detect/config.py +++ b/core/api/detect/config.py @@ -38,6 +38,14 @@ class StageOutputHintInfo(BaseModel): src_format: str = "png" +class TransformOptionInfo(BaseModel): + key: str + type: str + default: object = False + label: str = "" + description: str = "" + + class StageConfigInfo(BaseModel): name: str label: str @@ -45,6 +53,7 @@ class StageConfigInfo(BaseModel): category: str config_fields: list[dict] output_hints: list[StageOutputHintInfo] = [] + accepted_transforms: list[TransformOptionInfo] = [] reads: list[str] writes: list[str] @@ -87,6 +96,51 @@ def get_pipeline_config(profile_name: str): return profile["pipeline"] +class UpdateEdgeTransformRequest(BaseModel): + profile_name: str = "soccer_broadcast" + source_stage: str + target_stage: str + transform: dict + + +@router.put("/config/edge-transform") +def update_edge_transform(req: UpdateEdgeTransformRequest): + """Update the transform on an edge in a profile's pipeline config.""" + from uuid import UUID + from core.db.models import Profile + from core.db.connection import get_session + from sqlmodel import select + from fastapi import HTTPException + + with get_session() as session: + stmt = select(Profile).where(Profile.name == req.profile_name) + profile = session.exec(stmt).first() + if not profile: + raise HTTPException(status_code=404, detail=f"Profile not found: {req.profile_name}") + + pipeline = dict(profile.pipeline) + edges = pipeline.get("edges", []) + + found = False + for edge in edges: + if edge.get("source") == req.source_stage and edge.get("target") == req.target_stage: + edge["transform"] = req.transform + found = True + break + + if not found: + raise HTTPException( + status_code=404, + detail=f"Edge not found: {req.source_stage} → {req.target_stage}", + ) + + pipeline["edges"] = edges + profile.pipeline = pipeline + session.commit() + + return {"status": "updated", "edge": f"{req.source_stage} → {req.target_stage}", "transform": req.transform} + + @router.get("/config/stages", response_model=list[StageConfigInfo]) def list_stage_configs(): """Return the stage palette with config field metadata for the editor.""" @@ -137,6 +191,13 @@ def _stage_to_info(stage) -> StageConfigInfo: ) for h in getattr(stage, "output_hints", []) ], + accepted_transforms=[ + TransformOptionInfo( + key=t.key, type=t.type, default=t.default, + label=t.label, description=t.description, + ) + for t in getattr(stage, "accepted_transforms", []) + ], reads=stage.io.reads, writes=stage.io.writes, ) diff --git a/core/db/fixtures/soccer_broadcast.json b/core/db/fixtures/soccer_broadcast.json index cad76eb..5af76ba 100644 --- a/core/db/fixtures/soccer_broadcast.json +++ b/core/db/fixtures/soccer_broadcast.json @@ -54,7 +54,8 @@ }, { "source": "field_segmentation", - "target": "detect_edges" + "target": "detect_edges", + "transform": {"invert_mask": true} }, { "source": "field_segmentation", diff --git a/core/detect/graph/nodes.py b/core/detect/graph/nodes.py index e70f928..2b9961a 100644 --- a/core/detect/graph/nodes.py +++ b/core/detect/graph/nodes.py @@ -162,6 +162,16 @@ def node_detect_edges(state: DetectState) -> dict: field_masks = state.get("field_masks", {}) job_id = state.get("job_id") + # Apply edge transforms from upstream connections + edge_transforms = state.get("_edge_transforms", {}) + for source_stage, transform in edge_transforms.items(): + if transform.get("invert_mask") and field_masks: + import numpy as np + field_masks = { + seq: np.bitwise_not(mask) if mask is not None else None + for seq, mask in field_masks.items() + } + regions = detect_edge_regions( frames, config, inference_url=INFERENCE_URL, job_id=job_id, field_masks=field_masks, diff --git a/core/detect/graph/runner.py b/core/detect/graph/runner.py index 92461be..9f48cf2 100644 --- a/core/detect/graph/runner.py +++ b/core/detect/graph/runner.py @@ -213,6 +213,13 @@ class PipelineRunner: self.config = config self.do_checkpoint = checkpoint self.stage_sequence = _flatten_config(config, start_from) + # Build edge transform lookup: {target_stage: {source_stage: transform_dict}} + self._edge_transforms: dict[str, dict[str, dict]] = {} + for edge in config.edges: + if edge.transform: + if edge.target not in self._edge_transforms: + self._edge_transforms[edge.target] = {} + self._edge_transforms[edge.target][edge.source] = edge.transform def invoke(self, state: DetectState) -> DetectState: """Run the pipeline on the given state. Returns final state.""" @@ -224,6 +231,14 @@ class PipelineRunner: if check and check(): raise PipelineCancelled(f"Cancelled before {stage_name}") + # Inject edge transforms into state so the stage can read them. + # Compatible with LangGraph — just a state dict key. + transforms = self._edge_transforms.get(stage_name, {}) + if transforms: + state["_edge_transforms"] = transforms + elif "_edge_transforms" in state: + del state["_edge_transforms"] + # 2. Run node function node_fn = _NODE_FN_MAP.get(stage_name) if node_fn is None: diff --git a/core/detect/stages/field_segmentation.py b/core/detect/stages/field_segmentation.py index cf72b58..56a0498 100644 --- a/core/detect/stages/field_segmentation.py +++ b/core/detect/stages/field_segmentation.py @@ -25,6 +25,7 @@ from core.detect.stages.models import ( StageDefinition, StageIO, StageOutputHint, + TransformOption, ) logger = logging.getLogger(__name__) @@ -54,6 +55,9 @@ class FieldSegmentationStage(Stage): output_hints=[ StageOutputHint(key="mask_overlay_b64", type="overlay", label="Field mask", default_opacity=0.5, src_format="png"), ], + accepted_transforms=[ + TransformOption(key="invert_mask", type="bool", default=False, label="Invert selection", description="Invert the mask so downstream stages look outside the detected area"), + ], ) diff --git a/core/detect/stages/models.py b/core/detect/stages/models.py index 6225fd9..95788d7 100644 --- a/core/detect/stages/models.py +++ b/core/detect/stages/models.py @@ -35,6 +35,14 @@ class StageOutputHint(BaseModel): default_opacity: float = 0.5 src_format: str = "png" +class TransformOption(BaseModel): + """A transform the stage accepts on its incoming edges.""" + key: str + type: str + default: Any = False + label: str = "" + description: str = "" + class StageDefinition(BaseModel): """Complete metadata for a pipeline stage.""" name: str @@ -44,6 +52,7 @@ class StageDefinition(BaseModel): io: StageIO config_fields: List[StageConfigField] = Field(default_factory=list) output_hints: List[StageOutputHint] = Field(default_factory=list) + accepted_transforms: List[TransformOption] = Field(default_factory=list) tracks_element: Optional[str] = None class FrameExtractionConfig(BaseModel): @@ -105,6 +114,7 @@ class Edge(BaseModel): source: str target: str condition: str = "" + transform: Dict[str, Any] = Field(default_factory=dict) class PipelineConfig(BaseModel): """Pipeline graph topology + routing rules.""" @@ -112,4 +122,4 @@ class PipelineConfig(BaseModel): profile_name: str stages: List[StageRef] = Field(default_factory=list) edges: List[Edge] = Field(default_factory=list) - routing_rules: Dict[str, Any] + routing_rules: Dict[str, Any] = Field(default_factory=dict) diff --git a/core/schema/models/stage.py b/core/schema/models/stage.py index aa99d59..baae400 100644 --- a/core/schema/models/stage.py +++ b/core/schema/models/stage.py @@ -35,6 +35,16 @@ class StageIO: optional_reads: List[str] = field(default_factory=list) +@dataclass +class TransformOption: + """A transform the stage accepts on its incoming edges.""" + key: str + type: str # "bool", "float", "int", "str" + default: Any = False + label: str = "" + description: str = "" + + @dataclass class StageOutputHint: """How to render a stage output in the compare/editor views.""" @@ -55,6 +65,7 @@ class StageDefinition: io: StageIO = field(default_factory=StageIO) config_fields: List[StageConfigField] = field(default_factory=list) output_hints: List[StageOutputHint] = field(default_factory=list) + accepted_transforms: List[TransformOption] = field(default_factory=list) tracks_element: Optional[str] = None @@ -129,10 +140,16 @@ class StageRef: @dataclass class Edge: - """Connection between stages in the graph.""" + """Connection between stages in the graph. + + transform: per-edge data transformation spec. Flexible JSONB blob + type-checked by the consuming stage. E.g. {"invert_mask": true} + tells edge detection to invert the field segmentation mask. + """ source: str target: str condition: str = "" + transform: Dict[str, Any] = field(default_factory=dict) @dataclass @@ -151,6 +168,7 @@ STAGE_VIEWS = [ StageConfigField, StageIO, StageOutputHint, + TransformOption, StageDefinition, FrameExtractionConfig, SceneFilterConfig, diff --git a/ctrl/Tiltfile b/ctrl/Tiltfile index 4ba229f..4cc0627 100644 --- a/ctrl/Tiltfile +++ b/ctrl/Tiltfile @@ -4,9 +4,15 @@ allow_k8s_contexts('kind-mpr') +# Hard guard: Tilt deploys to whatever the shell's current kubectl context is, +# and allow_k8s_contexts auto-permits any local-looking (kind-*) cluster. +# Fail early instead of silently landing the workload in the wrong cluster. +if k8s_context() != 'kind-mpr': + fail("Wrong kubectl context: '%s'. Run: kubectl config use-context kind-mpr" % k8s_context()) + # Create namespace first — kustomize includes it but Tilt may apply -# all resources in parallel, causing "namespace not found" races -local('kubectl create namespace mpr --dry-run=client -o yaml | kubectl apply -f -') +# all resources in parallel, causing "namespace not found" races (pin cluster explicitly) +local('kubectl --context kind-mpr create namespace mpr --dry-run=client -o yaml | kubectl --context kind-mpr apply -f -') # Apply k8s manifests via kustomize (dev overlay) k8s_yaml(kustomize('k8s/overlays/dev')) diff --git a/ctrl/docker-compose.yml b/ctrl/docker-compose.yml index 13402d3..80dd26a 100644 --- a/ctrl/docker-compose.yml +++ b/ctrl/docker-compose.yml @@ -202,7 +202,7 @@ services: - ../ui/detection-app/src:/app/src - ../ui/detection-app/vite.config.ts:/app/vite.config.ts - ../ui/detection-app/index.html:/app/index.html - - ../ui/framework:/app/node_modules/mpr-ui-framework + - ../ui/framework:/app/node_modules/soleprint-ui volumes: postgres-data: diff --git a/ctrl/k8s/kind-config.yaml b/ctrl/k8s/kind-config.yaml index e05ad0f..df5d39a 100644 --- a/ctrl/k8s/kind-config.yaml +++ b/ctrl/k8s/kind-config.yaml @@ -4,10 +4,10 @@ name: mpr nodes: - role: control-plane extraPortMappings: - # Gateway → http://k8s.mpr.local.ar (bind to 127.0.0.2 to avoid conflict with docker-compose on 127.0.0.1:80) + # Gateway → routed via Caddy on port 80 (mpr.local.ar, k8s.mpr.local.ar) - containerPort: 30080 - hostPort: 80 - listenAddress: "127.0.0.2" + hostPort: 30080 + listenAddress: "127.0.0.1" protocol: TCP # Redis - containerPort: 30379 diff --git a/modelgen/.spr b/modelgen/.spr new file mode 100644 index 0000000..3f2eb61 --- /dev/null +++ b/modelgen/.spr @@ -0,0 +1,7 @@ +name=soleprint-modelgen +version=0.2.0 +type=pip +sha=0822761 +mode=published +updated=2026-04-12T00:22:10Z +source=/home/mariano/wdir/spr/soleprint/station/tools/modelgen diff --git a/modelgen/generator/pydantic.py b/modelgen/generator/pydantic.py index 75077fd..866f3b9 100644 --- a/modelgen/generator/pydantic.py +++ b/modelgen/generator/pydantic.py @@ -328,7 +328,12 @@ class PydanticGenerator(BaseGenerator): if isinstance(default, Enum): return f" = {default.__class__.__name__}.{default.name}" if callable(default): - return " = Field(default_factory=list)" if "list" in str(default) else "" + default_str = str(default) + if "list" in default_str: + return " = Field(default_factory=list)" + if "dict" in default_str: + return " = Field(default_factory=dict)" + return "" return f" = {default!r}" def _generate_from_config(self, config) -> str: diff --git a/modelgen/pyproject.toml b/modelgen/pyproject.toml new file mode 100644 index 0000000..4f30d69 --- /dev/null +++ b/modelgen/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["setuptools>=68.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "soleprint-modelgen" +version = "0.2.0" +description = "Multi-source, multi-target model code generator" +requires-python = ">=3.10" +dependencies = [] + +[project.scripts] +modelgen = "modelgen.__main__:main" + +[tool.setuptools.packages.find] +include = ["modelgen*"] diff --git a/ui/detection-app/package.json b/ui/detection-app/package.json index 6c702b6..b4525a6 100644 --- a/ui/detection-app/package.json +++ b/ui/detection-app/package.json @@ -11,8 +11,10 @@ }, "dependencies": { "@techstark/opencv-js": "4.12.0-release.1", - "mpr-ui-framework": "link:../framework", + "@vue-flow/core": "^1.48.2", + "soleprint-ui": "link:../framework", "pinia": "^2.2", + "uplot": "^1.6", "vue": "^3.5" }, "devDependencies": { diff --git a/ui/detection-app/pnpm-lock.yaml b/ui/detection-app/pnpm-lock.yaml index 0f4adac..97ff2ab 100644 --- a/ui/detection-app/pnpm-lock.yaml +++ b/ui/detection-app/pnpm-lock.yaml @@ -11,12 +11,12 @@ importers: '@techstark/opencv-js': specifier: 4.12.0-release.1 version: 4.12.0-release.1 - mpr-ui-framework: - specifier: link:../framework - version: link:../framework pinia: specifier: ^2.2 version: 2.3.1(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)) + soleprint-ui: + specifier: link:../framework + version: link:../framework vue: specifier: ^3.5 version: 3.5.30(typescript@5.9.3) diff --git a/ui/detection-app/src/App.vue b/ui/detection-app/src/App.vue index 6dc6eb5..f0aef02 100644 --- a/ui/detection-app/src/App.vue +++ b/ui/detection-app/src/App.vue @@ -1,7 +1,7 @@