diff --git a/conf/models/logical_extraction/dummy.yaml b/conf/models/logical_extraction/dummy.yaml new file mode 100644 index 0000000..38effdf --- /dev/null +++ b/conf/models/logical_extraction/dummy.yaml @@ -0,0 +1 @@ +_target_: discoverex.adapters.outbound.models.dummy.DummyLogicalExtraction diff --git a/conf/models/logical_extraction/moondream2.yaml b/conf/models/logical_extraction/moondream2.yaml new file mode 100644 index 0000000..3d3f011 --- /dev/null +++ b/conf/models/logical_extraction/moondream2.yaml @@ -0,0 +1,5 @@ +_target_: discoverex.adapters.outbound.models.hf_moondream2.Moondream2Adapter +model_id: "vikhyat/moondream2" +quantization: 4bit +device: cuda +dtype: float16 diff --git a/conf/models/physical_extraction/dummy.yaml b/conf/models/physical_extraction/dummy.yaml new file mode 100644 index 0000000..9ea37a7 --- /dev/null +++ b/conf/models/physical_extraction/dummy.yaml @@ -0,0 +1 @@ +_target_: discoverex.adapters.outbound.models.dummy.DummyPhysicalExtraction diff --git a/conf/models/physical_extraction/mobilesam.yaml b/conf/models/physical_extraction/mobilesam.yaml new file mode 100644 index 0000000..fdc28e6 --- /dev/null +++ b/conf/models/physical_extraction/mobilesam.yaml @@ -0,0 +1,6 @@ +_target_: discoverex.adapters.outbound.models.hf_mobilesam.MobileSAMAdapter +model_id: "ChaoningZhang/MobileSAM" +device: cuda +dtype: float16 +# frozen hyperparam: step function → not differentiable, excluded from WeightFitter +cluster_radius_factor: 0.5 # cluster_radius = mean_dist * factor diff --git a/conf/models/visual_verification/dummy.yaml b/conf/models/visual_verification/dummy.yaml new file mode 100644 index 0000000..2a40a05 --- /dev/null +++ b/conf/models/visual_verification/dummy.yaml @@ -0,0 +1 @@ +_target_: discoverex.adapters.outbound.models.dummy.DummyVisualVerification diff --git a/conf/models/visual_verification/yolo_clip.yaml b/conf/models/visual_verification/yolo_clip.yaml new file mode 100644 index 0000000..543c174 --- /dev/null +++ b/conf/models/visual_verification/yolo_clip.yaml @@ -0,0 +1,8 @@ +_target_: discoverex.adapters.outbound.models.hf_yolo_clip.YoloCLIPAdapter +yolo_model_id: "THU-MIG/yolov10-n" +clip_model_id: "openai/clip-vit-base-patch32" +# frozen hyperparams: step functions → not differentiable, excluded from WeightFitter +sigma_levels: [1.0, 2.0, 4.0, 8.0, 16.0] # discrete blur grid +iou_match_threshold: 0.3 # IoU cutoff for object-disappeared detection +device: cuda +max_vram_gb: 4.0 diff --git a/conf/validator.yaml b/conf/validator.yaml new file mode 100644 index 0000000..72a4483 --- /dev/null +++ b/conf/validator.yaml @@ -0,0 +1,37 @@ +defaults: + - models/physical_extraction: mobilesam + - models/logical_extraction: moondream2 + - models/visual_verification: yolo_clip + - adapters/artifact_store: local + - adapters/metadata_store: local_json + - adapters/tracker: mlflow_file + - adapters/scene_io: json + - adapters/report_writer: local_json + - runtime/model_runtime: gpu + - runtime/env: default + - _self_ + +thresholds: + is_hidden_min_conditions: 2 + pass_threshold: 0.23 # MVP: 0.35 → 0.23 (데이터 확보 우선, 학습 후 상향 조정) + +# weights_path: null # 학습된 가중치 JSON 경로. 지정 시 weights: 섹션 무시. +weights_path: null + +# MVP 데이터 수집: VerificationBundle 을 JSON 파일로 저장할 디렉터리. +# null 이면 저장하지 않음. label 필드는 null 로 저장되어 나중에 채울 수 있음. +bundle_store_dir: "engine/src/ML/data" + +weights: + perception_sigma: 0.50 + perception_drr: 0.50 + logical_hop: 0.55 + logical_degree: 0.45 + total_perception: 0.45 + total_logical: 0.55 + difficulty_occlusion: 0.25 + difficulty_sigma: 0.20 + difficulty_hop: 0.20 + difficulty_degree: 0.15 + difficulty_drr: 0.20 + difficulty_interaction: 0.10 diff --git a/docs/Validator/DIR.md b/docs/Validator/DIR.md new file mode 100644 index 0000000..02fe13a --- /dev/null +++ b/docs/Validator/DIR.md @@ -0,0 +1,287 @@ +# Discoverex Engine - 디렉토리 구조 문서 + +> 생성일: 2026-03-03 +> 대상 경로: `/home/user/discoverex/engine` + +--- + +## 최상위 구조 + +``` +engine/ +├── src/discoverex/ # 핵심 소스 코드 (Hexagonal Architecture) +├── delivery/ # 프론트엔드 번들 변환 레이어 +├── conf/ # Hydra 설정 파일 +├── tests/ # 단위/통합 테스트 +├── orchestrator/ # Prefect 워크플로우 래퍼 +├── infra/ # 인프라 구성 (Docker Compose) +├── docs/ # 프로젝트 문서 +├── scripts/ # 유틸리티 스크립트 +├── .context/ # 아키텍처 핸드오프 문서 +├── .devcontainer/ # VSCode 개발 컨테이너 설정 +├── .github/workflows/ # CI/CD 파이프라인 +├── main.py # CLI 진입점 래퍼 +├── pyproject.toml # UV 프로젝트 설정 / 의존성 정의 +└── Makefile # 빌드 자동화 (sync, test, lint, typecheck, run) +``` + +--- + +## 소스 코드 계층 (`src/discoverex/`) + +### 도메인 레이어 - `domain/` +비즈니스 엔티티와 규칙의 핵심. 프레임워크 의존성 없음. + +| 파일 | 설명 | +|------|------| +| `scene.py` | 루트 엔티티: `Scene`, `SceneMeta`, `Background`, `Composite`, `Answer`, `Difficulty`, `ObjectGroup`. answer region ID 존재 검증 포함 | +| `region.py` | `Region` 엔티티: `Geometry`(BBOX + mask_ref), `RegionRole`(candidate/distractor/answer/object), `RegionSource`(candidate_model/inpaint/fx/manual) | +| `goal.py` | `Goal`: `GoalType`(relation/count/shape/semantic), `AnswerForm`(region_select/click_one/click_multiple), 제약 구조 | +| `verification.py` | `VerificationResult`(score+pass+signals), `FinalVerification`(total_score+pass+reason), `VerificationBundle`(logical+perception+final) | +| `services/verification.py` | 도메인 서비스: `run_logical_verification()`, `integrate_verification()`, **`resolve_answer()`**, **`compute_difficulty()`**, **`compute_scene_difficulty()`**, **`integrate_verification_v2()`** — Validator Phase 4 순수 수식 포함 | +| `services/judgement.py` | 판정 도메인 서비스 | + +### 애플리케이션 레이어 - `application/` +유스케이스 조율 및 포트(인터페이스) 정의. + +#### 포트 (Hexagonal 경계 인터페이스) - `application/ports/` + +| 파일 | 프로토콜 | 설명 | +|------|----------|------| +| `models.py` | `HiddenRegionPort`, `InpaintPort`, `PerceptionPort`, `FxPort` (기존 load/predict 패턴) + **`PhysicalExtractionPort`**, **`LogicalExtractionPort`**, **`VisualVerificationPort`** (Validator load/extract\|verify/unload 패턴) | +| `storage.py` | `ArtifactStorePort`, `MetadataStorePort` | 아티팩트 및 메타데이터 저장소 추상화 | +| `tracking.py` | `TrackerPort` | MLflow 실험 추적 추상화 | +| `io.py` | `SceneIOPort` | Scene JSON 입출력 추상화 | +| `reporting.py` | `ReportWriterPort` | 리포트 생성 추상화 | + +#### 유스케이스 - `application/use_cases/` + +| 파일 | 설명 | +|------|------| +| `gen_verify/orchestrator.py` | 메인 오케스트레이터: 배경→영역→검증→패키징 | +| `gen_verify/scene_builder.py` | 파이프라인 출력으로부터 Scene 엔티티 빌드 | +| `gen_verify/region_pipeline.py` | 숨은 영역 감지 + 인페인팅 파이프라인 | +| `gen_verify/verification_pipeline.py` | 논리적 + 지각적 검증 | +| `gen_verify/composite_pipeline.py` | 최종 이미지 합성 | +| `gen_verify/persistence.py` | Scene 저장 + 메타데이터 업데이트 | +| `gen_verify/types.py` | 파이프라인 내부 데이터 타입 | +| `verify_only.py` | 기존 Scene 재검증 | +| `replay_eval.py` | 다수 Scene 일괄 평가 | +| **`validator/__init__.py`** | **ValidatorOrchestrator, run_validator export** | +| **`validator/orchestrator.py`** | **4단계 순차 파이프라인 오케스트레이터 (VRAM 바톤 터치 전략, try/finally 보장)** | + +### 어댑터 레이어 - `adapters/` + +#### 인바운드 - `adapters/inbound/cli/` +**main.py (Typer CLI):** 4개 명령어 +- `gen-verify --background-asset-ref` → 생성 + 검증 실행 +- `verify-only --scene-json` → 저장된 Scene 재검증 +- `replay-eval --scene-jsons` → 일괄 평가 +- **`validate --composite-image --object-layer` → Validator 파이프라인 실행 (JSON 결과 출력)** + +#### 아웃바운드 - `adapters/outbound/` + +**모델 어댑터:** +| 파일 | 설명 | +|------|------| +| `dummy.py` | 테스트용 목(Mock) 모델 — 기존 4종 + **`DummyPhysicalExtraction`, `DummyLogicalExtraction`, `DummyVisualVerification`** | +| `hf_*.py` | HuggingFace Transformers 구현체 (fx, hidden_region, inpaint, perception) | +| **`hf_mobilesam.py`** | **Phase 1: MobileSAM 기반 물리 메타데이터 추출 (occlusion, z_index, z_depth_hop BFS, cluster_density, euclidean_distance)** | +| **`hf_moondream2.py`** | **Phase 2: Moondream2 4-bit VLM 기반 논리 관계 추출 (encode_image → NetworkX graph → degree/hop/diameter)** | +| **`hf_yolo_clip.py`** | **Phase 3: YOLOv10-N + CLIP 병렬 로드 (IoU 기반 sigma_threshold, bbox-crop per-object DRR)** | +| `tiny_hf_*.py` | 경량 HuggingFace 변형 | +| `tiny_torch_*.py` | 경량 PyTorch 변형 | +| `runtime.py` | 디바이스/dtype/배치 관리 | +| `fx_artifact.py` | FX 출력 아티팩트 처리 | + +**저장소 어댑터:** +| 파일 | 설명 | +|------|------| +| `storage/artifact.py` | `LocalArtifactStoreAdapter` (JSON to 디스크) + `MinioArtifactStoreAdapter` (S3 호환, boto3) | +| `storage/metadata.py` | `LocalMetadataStoreAdapter` (JSON 인덱스) + `PostgresMetadataStoreAdapter` | + +**기타 아웃바운드:** +| 파일 | 설명 | +|------|------| +| `tracking/mlflow.py` | MLflow 실험 추적 어댑터 | +| `io/json_scene.py` | JSON Scene 로더/세이버 | +| `reports/reports.py` | 리포트 작성 구현체 | + +### 부트스트랩 (Composition Root) - `bootstrap/` + +| 파일 | 설명 | +|------|------| +| `container.py` | 구체 어댑터 인스턴스화 | +| `factory.py` | `build_context()` — Hydra 기반 DI 컨테이너 구성 + **`build_validator_context()`** — ValidatorPipelineConfig → ValidatorOrchestrator 직접 생성 | +| `context.py` | `AppContext` 구체 데이터클래스 (`AppContextLike` 프로토콜 구현) | +| `config_defaults.py` | 설정 해석 로직 | + +### 설정 스키마 - `config/` + +| 파일 | 설명 | +|------|------| +| `schema.py` | Pydantic 설정 모델: `ModelsConfig`, `AdaptersConfig`, `RuntimeModelConfig`, `RuntimeEnvConfig`, `ThresholdsConfig`, `ModelVersionsConfig`, `PipelineConfig` + **`ValidatorModelsConfig`, `ValidatorThresholdsConfig`, `ValidatorPipelineConfig`** (PipelineConfig와 독립, extra="forbid") | +| `config_loader.py` | `load_pipeline_config()` — Hydra + OmegaConf → Pydantic 검증 + **`load_validator_config()`** — ValidatorPipelineConfig 반환 | + +### 모델 타입 - `models/` + +| 파일 | 설명 | +|------|------| +| `types.py` | `ModelHandle`, `HiddenRegionRequest`, `InpaintRequest`, `PerceptionRequest`, `FxRequest`, `FxPrediction` + **`PhysicalMetadata`, `LogicalStructure`, `VisualVerification`, `ValidatorInput`** (Validator Phase 1~4 I/O 타입) | + +--- + +## 설정 파일 (`conf/`) + +``` +conf/ +├── gen_verify.yaml # gen-verify 파이프라인 기본 설정 +├── verify_only.yaml # verify-only 기본 설정 +├── replay_eval.yaml # replay-eval 기본 설정 +├── validator.yaml # [NEW] Validator 파이프라인 Hydra 진입점 +├── models/ +│ ├── hidden_region/ # dummy, hf, tiny_hf, tiny_torch +│ ├── inpaint/ # dummy, hf, tiny_hf, tiny_torch +│ ├── perception/ # dummy, hf, tiny_hf, tiny_torch +│ ├── fx/ # dummy, hf, tiny_hf, tiny_torch +│ ├── physical_extraction/ # [NEW] mobilesam.yaml, dummy.yaml +│ ├── logical_extraction/ # [NEW] moondream2.yaml, dummy.yaml +│ └── visual_verification/ # [NEW] yolo_clip.yaml, dummy.yaml +├── adapters/ +│ ├── artifact_store/ # local.yaml, minio.yaml +│ ├── metadata_store/ # local_json.yaml, postgres.yaml +│ ├── tracker/ # mlflow_file.yaml, mlflow_server.yaml +│ ├── scene_io/ # json.yaml +│ └── report_writer/ # local_json.yaml +└── runtime/ + ├── model_runtime/ # cpu.yaml, gpu.yaml + └── env/ # default.yaml +``` + +--- + +## 딜리버리 레이어 (`delivery/spot_the_hidden/`) + +프론트엔드 번들 생성을 위한 별도 패키지. + +| 파일 | 설명 | +|------|------| +| `schema.py` | `GameBundle` = Scene 참조 + `PlayableScene`(이미지, 목표, 힌트, UI 플래그) + `AnswerKey`(정답 region IDs) + `DeliveryMeta`(저장 타입, 이미지 해시, 상태, 난이도) | +| `cli.py` | Scene JSON → GameBundle JSON 변환 CLI | +| `converter.py` | `Scene` → `GameBundle` 변환 로직 | +| `io.py` | GameBundle I/O 작업 | +| `README.md` | 딜리버리 패키지 문서 | + +--- + +## 테스트 (`tests/`) + +| 파일 | 설명 | +|------|------| +| `test_config_schema.py` | 설정 스키마 검증 | +| `test_config_tracking_uri.py` | 추적 URI 설정 | +| `test_architecture_constraints.py` | Hexagonal 경계 강제 | +| `test_model_ports_contract.py` | 모델 포트 인터페이스 계약 | +| `test_hexagonal_boundaries.py` | 어댑터 임포트 제한 검증 | +| `test_fx_output_artifact.py` | FX 아티팩트 처리 | +| `test_fx_prediction_contract.py` | FX 예측 인터페이스 | +| `test_tiny_model_pipeline_smoke.py` | E2E 스모크 테스트 | +| `test_gen_verify_composite.py` | gen-verify 합성 | +| `test_artifact_verification_consistency.py` | 아티팩트 일관성 | +| **`test_validator_pipeline_smoke.py`** | **Dummy 어댑터 기반 Validator E2E 스모크 테스트 (5개)** | +| **`test_validator_scoring.py`** | **Phase 4 순수 수식 단위 테스트 (21개): resolve_answer, compute_difficulty, compute_scene_difficulty, integrate_verification_v2** | +| `delivery/test_schema_validation.py` | 딜리버리 번들 스키마 | +| `delivery/test_converter_mapping.py` | Scene→Bundle 변환 | +| `delivery/test_scene_to_bundle_artifact.py` | 번들 아티팩트 생성 | +| `delivery/test_front_payload_strips_answer_key.py` | 정답키 제거 검증 | + +--- + +## 오케스트레이터 (`orchestrator/`) + +| 파일 | 설명 | +|------|------| +| `prefect_flows.py` | Prefect 플로우 래퍼: `gen_verify_flow()`(재시도 2회, 3초 딜레이), `verify_only_flow()`, `replay_eval_flow()` | + +--- + +## 인프라 (`infra/`) + +| 파일 | 설명 | +|------|------| +| `docker-compose.yml` | 서비스: PostgreSQL + MinIO + MLflow (+ 선택적 Airflow 프로파일) | + +--- + +## 문서 (`docs/`) + +``` +docs/ +├── pipeline-adapter-guide.md # 신규 모델/저장소/추적 어댑터 추가 가이드 +├── handheld-ops-card.md # 운영 빠른 참조 +├── execution-contract.md # 파이프라인 실행 보장 사항 +└── Validator/ + ├── instruction_1.md # Validator 파이프라인 설계 명세 (4단계 연쇄 추출) + ├── DIR.md # 현재 파일 - 디렉토리 구조 문서 + └── plan_pipeline.md # Validator 파이프라인 구현 계획 +``` + +--- + +## 컨텍스트 문서 (`.context/`) + +| 파일 | 설명 | +|------|------| +| `overview.md` | 프로젝트 아키텍처 및 핸드오프 가이드 | +| `HANDOFF.md` | 검증된 실행 상태 포함 핸드오프 문서 | +| `canon.md` | 표준 Scene 계약 참조 | +| `git-conventions.md` | Git 워크플로우 (브랜치 명명, 커밋 접두사) | + +--- + +## 핵심 의존성 + +| 그룹 | 패키지 | +|------|--------| +| Core | `pydantic>=2.12.5`, `typer>=0.24.1`, `hydra-core>=1.3.2` | +| Orchestration | `prefect>=3.6.20` | +| Tracking | `mlflow>=3.10.0` | +| Storage | `boto3`, `psycopg[binary]`, `sqlalchemy` | +| ML CPU | `torch`, `transformers` | +| ML GPU | `torch`, `transformers`, `accelerate` | +| **Validator** | **`mobile-sam>=1.0.0`, `ultralytics>=8.0.0`, `bitsandbytes>=0.41.0`, `networkx>=3.0`, `pillow>=11.3.0`, `numpy>=1.26.0`** | +| Dev | `mypy`, `pytest`, `ruff`, `pyyaml` | + +--- + +## Validator 파이프라인 대상 모델 (instruction_1.md 기준) + +| Phase | 모델 | VRAM 전략 | 출력 | +|-------|------|-----------|------| +| Phase 1 | MobileSAM | 단독 로드 → 해제 | `physical_metadata.json` | +| Phase 2 | Moondream2 (4-bit) | 단독 로드 → 해제 | `logical_structure.json` | +| Phase 3 | YOLOv10-N + CLIP | 병렬 로드 (4GB 미만) | `visual_verification.json` | +| Phase 4 | 없음 (순수 연산) | VRAM 불필요 | `scene.json` (CANON) | + +--- + +## 데이터 플로우 아키텍처 + +``` +배경 이미지 + 오브젝트 레이어 + │ + ▼ +[Phase 1] MobileSAM - 물리 메타데이터 추출 + │ physical_metadata.json + ▼ +[Phase 2] Moondream2 - 논리 관계 추출 (Scene Graph) + │ logical_structure.json + ▼ +[Phase 3] YOLOv10-N + CLIP - 시각적 난이도 검증 + │ visual_verification.json + ▼ +[Phase 4] 순수 연산 - 정답 판정 + CANON 조립 + │ scene.json + ▼ +Delivery CLI → 백엔드 전송 +``` diff --git a/docs/Validator/instruction_1.md b/docs/Validator/instruction_1.md new file mode 100644 index 0000000..3c2b09d --- /dev/null +++ b/docs/Validator/instruction_1.md @@ -0,0 +1,224 @@ +# [2] 생성 검증 부분 + +## 채택한 난이도 측정 요소 +가장 먼저 선행되어야 하는건 "숨어있다"를 판단할 기준이고, 그 다음에 이어지는 숨은 정도가 난이도의 형태로 구현되어야 한다. + +*참고로 현재 물체라고 정의한 것은 배경과는 별개로 분리된 오브젝트 레이어들이다. + +-------------- + +### 현재 이미지로부터 얻을 수 있는 정보의 종류 +- 📍 **from. Visual MetaData** + : 시각적으로 혼란을 주는 요소들중 정량적인 부분이 난이도를 결정한다고 판단 + 물리적인 요소) 물체의 위치 | 물체의 크기 | 물체의 숫자 + 관계적인 요소) 물체간의 거리(유클리드 거리) | 물체의 크기 편차 | 2D 좌표계 기반 가림 정도(IoU) + +- 📍 **from. Gaussian Blur(VSC Extension)** + : 객체 식별에 필요한 최소 해상도 정보를 추출한다. + 물리적인 요소) 블러수준 별 남은 물체의 수 | 물체 별 가할 수 있는 블러 수준 + +- 📍 **from. Scene Graph** + : 물체가 가진 논리적인 관계의 수와 노드의 깊이가 난이도를 결정한다고 판단 + 물리적인 요소) 노드와 엣지의 수 | 노드의 차수 + 관계적인 요소) 노드를 연결한 그래프 직경(diameter) + + +) 노드의 차수: 노드가 가지는 엣지의 수 + - 주변에 많은 물체와 물리적으로 맞닿아 있다거나 특정 그룹의 중심에 서있음을 의미한다. + - 주변 사물과의 경계가 모호해서 인지적인 혼동을 일으킨다 + - 단순히 많은 차수보단 유사한 색상을 가진 객체와 엣지를 가지는 경우에 난이도가 대폭 상승한다 + + +) 객체의 깊이(Hop)과 그래프 직경(diameter): hop은 논리적인 단계를, diameter은 장면의 전반적인 복잡도를 나타낸다. + + - 2D 최적화: 고비용 3D 렌더링 대신 레이어 Z-index 트리 상의 Shortest Path(Hop)를 통해 매몰 깊이 산출 + - 매몰 깊이는 원근적인 중첩도를 대변하게 되어 중심부에 있음에도 발견이 어려운 오브젝트를 판별할 수 있다 + +------------------------- + +## 1. "숨어있다"를 판단하는 기준 + + **시각적인 요소** +> - 유사정도가 높은 객체가 여러 개 존재하는 경우 +> - 타겟과 비슷한 특징을 가진 물체가 타겟으로부터 떨어진 평균거리 +> - 군집의 밀집도 | 중심과 타겟 반경을 기준으로 측정 +> - 블러 임계점 기반 식별 무결성 | 단계별 블러 적용시 타겟 윤곽이 배경에 합쳐지는 최소 블러레벨을 측정(소실여부 판정) +> - !색상 대비 지수(MVP에선 보류) +> - !경계 강도(MVP에선 보류) + + + **논리적인 요소** +> - Logic Query Complexity(LQC)= 객체의 깊이(hop) + 그래프 직경(diameter) | 최상단 노드로부터 타겟까지의 매몰 깊이를 산출해서 장면 중심부에 있음에도 발견이 어려운 오브젝트를 판별 +> - 노드의 차수(Degree) | 1차적으로 물리적인 경계가 모호해서 어렵다. 2차적으로 색상이 유사하거나 겹치는 특징이 많은 노드와의 엣지가 많다면 난이도가 기하급수적으로 올라간다. + +## 2. 시각적인 난이도 +: 아래 요소들을 종합적으로 조합해서 정해지는 숨은 정도에 대한 점수를 의미한다. + +- 객체의 차수 +- 시각적인 가림 비율 +- 객체 깊이(hop) + 그래프 직경(diameter) +- 군집 밀집도 | 타겟 반경 내에 존재하는 유사 객체의 수 (or) 단순히 반경내에 존재하는 객체의 수 +- 블러 임계점 기반 발견 난이도를 책정 (or) 디테일 잔존율을 수치화 + + +# [4] 최종 파이프라인 +# 🏗️ VRAM 8GB 최적화: 이미지 기반 4단계 연쇄 추출 파이프라인 + +본 파이프라인은 제한된 VRAM 자원을 극대화하기 위해 **"순차적 로드 및 강제 해제"** 전략과 **"내부 병렬 처리"**를 혼합하여 설계되었습니다. + +--- + +## 🔄 실행 시퀀스 및 메모리 전략 +1. **[Phase 1] 물리 데이터 추출** (MobileSAM) → **VRAM 초기화** +2. **[Phase 2] 논리 관계 추출** (Moondream2) → **VRAM 초기화** +3. **[Phase 3] 시각 난이도 검증** (YOLO + CLIP 병렬) → **VRAM 종료** +4. **[Phase 4] 정답 판정 + CANON 조립** (순수 연산) → **scene.json 출력** + +--- + +## 📍 Phase 1: 물리적 지표 추출 (Visual Metadata) +이미지의 기하학적 기초 데이터를 형성하여 이후 단계의 좌표 가이드 역할을 수행합니다. + +* **모델:** [MobileSAM](https://github.com/ChaoningZhang/MobileSAM) (경량화 Segmentation) +* **입력:** 합성 이미지 ($I_{composite}$) + 개별 오브젝트 레이어 ($obj\_*.png$) +* **연쇄 프로세스:** + 1. **[Pre-processing]** 개별 레이어 vs composite 픽셀 비교를 통해 오브젝트 간 가림 관계 확정 → **z_index, occlusion_ratio, z_depth_hop** 산출. *(순수 연산)* + 2. MobileSAM을 통한 모든 객체의 **정밀 Mask** 생성. + 3. 마스크 픽셀 좌표 분석: **B-box, 중심점($x, y$), 객체 수** 산출. + 4. 기하학적 연산: 객체 간 **유클리드 거리**, **군집 밀집도(반경 내 객체 수)** 계산. + 5. Pre-processing 결과 병합. +* **출력:** `physical_metadata.json` (좌표, 가림 정보, 군집 정보) +* **메모리 관리:** 분석 종료 후 `del model` 및 `torch.cuda.empty_cache()` 호출로 **VRAM 점유율 0%** 유도. + +--- + +## 📍 Phase 2: 논리적 관계 추출 (Scene Graph) +물리적 좌표를 바탕으로 객체 간의 고차원적 의미 관계와 레이어 깊이를 해석합니다. + +* **모델:** [Moondream2](https://github.com/vikhyat/moondream) (4-bit Quantized VLM) +* **입력:** 합성 이미지 ($I_{composite}$) + **Phase 1의 좌표 메타데이터** +* **연쇄 프로세스:** + 1. **Context-Aware Prompting:** Phase 1에서 얻은 좌표를 프롬프트에 주입하여 모델의 추론 정확도 향상. + 2. **Semantic 분석:** 객체 간 공간/논리 관계(예: "A는 B에 의해 가려짐", "C 내부에 D 존재")를 JSON으로 추출. + 3. **Graph 연산 (NetworkX):** 관계 리스트를 그래프화하여 **노드 차수(Degree), 매몰 깊이(Hop), 그래프 직경(Diameter)** 산출. +* **출력:** `logical_structure.json` (관계망 및 계층 지표) +* **메모리 관리:** 가장 무거운 단계이므로 실행 후 **VRAM 완전 비움**. +--- + +## 📍 Phase 3: 시각적 난이도 검증 (VSC Extension - 병렬) +이미지에 변형(블러)을 가해 앞서 확보된 데이터들의 시각적 식별 한계를 정량화합니다. + +* **모델:** [YOLOv10-N](https://github.com/THU-MIG/yolov10) + [CLIP (ViT-B/32)](https://github.com/openai/CLIP) (병렬 로드) +* **입력:** 합성 이미지 ($I_{composite}$) ➔ 블러 이미지 세트 ($\sigma$ = 1, 2, 4, 8, 16) 생성 +* **병렬 연쇄 프로세스:** + 1. **Shared Input:** 동일한 블러 이미지 배치를 두 모델에 동시 입력하여 메모리 I/O 최소화. + 2. **YOLO Branch:** 객체 식별 성공 여부 및 Confidence 변화를 추적하여 **소실 임계 블러레벨($\sigma_{threshold}$)** 확보. + 3. **CLIP Branch (Self-Similarity):** 원본 대비 특징 벡터 코사인 유사도를 계산하여 **디테일 잔존율(Detail Retention Rate)** 산출. + 4. **통합:** AI의 식별 한계점과 시각 데이터 파괴도를 결합하여 시각적 난이도 지수 확정. +* **출력:** `visual_verification.json` (블러 임계점, 자기 유사성) +* **메모리 관리:** 두 모델의 합계 점유율을 **4GB 미만**으로 유지하며 최종 지표 산출 후 종료. + +--- + +## 📍 Phase 4: 정답 판정 + CANON Assembler +앞선 세 Phase의 출력을 집계하여 정답을 확정하고 최종 Scene 객체를 조립합니다. *(모델 없음, 순수 연산)* + +### **입력:** +`physical_metadata.json` + `logical_structure.json` + `visual_verification.json` + +### **연쇄 프로세스:** +* 1. **"숨어있다" 판정 (Answer Resolver):** 아래 조건 중 2개 이상 충족 시 정답 오브젝트로 확정 + * `occlusion_ratio > 0.3` (30% 이상 가려짐) + * `σ_threshold ≤ 4` (낮은 블러 수준에서 소실) + * `degree ≥ 3` (많은 논리 관계 연결) + * `z_depth_hop ≥ 2` (레이어 깊이 매몰) + * `neighbor_count ≥ 3` (군집 내 위치) + +* 2. **난이도 산출:** 정답 오브젝트들의 측정값을 선형 결합으로 집계 + +D(obj) = 0.25 · occlusion_ratio² + + 0.20 · (1 / σ_threshold) + + 0.20 · (hop / diameter) + + 0.15 · degree_norm² + + 0.20 · (1 - DRR) + + w_ix · occlusion_ratio · (hop / diameter) + +Scene_Difficulty = (1 / |answer|) · Σ D(obj) where obj ∈ answer + + +* 3. **VerificationBundle 집계:** perception / logical 점수를 가중 합산하여 최종 pass/fail 판정 + +total_score = perception × 0.45 + logical × 0.55 + + perception = 0.20 · (1 / σ) + 0.20 · (1 - DRR) + logical = 0.20 · (hop / diameter) + 0.15 · degree_norm² + +`pass = total_score >= 0.35 / failure_reason= difficulty_too_low if not pass` + + +* 4. **Scene 조립:** 전체 필드를 CANON 스키마에 맞게 조립 후 `validate_answer_regions` 검증. + * **출력:** `scene.json` (CANON 완성) ➔ Delivery CLI ➔ 백엔드 전송 + +--- + +## 📋 파이프라인 요약 테이블 + +| 단계 | 실행 순서 | 주요 모델 | VRAM 전략 | 핵심 산출물 | +| :--- | :--- | :--- | :--- | :--- | +| **Phase 1** | **1순위** | MobileSAM | 단독 로드/해제 | 물리 좌표, IoU, 군집 밀집도 | +| **Phase 2** | **2순위** | Moondream2 | 단독 로드/해제 | 매몰 깊이(Hop), 관계망, Diameter | +| **Phase 3** | **3순위** | YOLO + CLIP | 병렬 로드 | 블러 임계점, 디테일 잔존율 | +| **Phase 4** | **4순위** | 없음 (순수 연산) | VRAM 불필요 | scene.json (CANON) | + +--- + +## 💡 Implementation Note +1. **메모리 바톤 터치:** 각 Phase는 독립적인 `function`으로 구현하며, 데이터는 `return` 값인 JSON 구조체로만 전달합니다. +2. **데이터 무결성:** Phase 2 실행 시 Phase 1의 좌표를 프롬프트로 전달함으로써, 모델이 엉뚱한 물체를 인식하는 현상을 방지합니다. +3. **수치적 정교함:** Phase 3의 **Self-Similarity** 지표는 YOLO의 Pass/Fail 결과에 '질적 정보량'을 더해주는 핵심 보조 지표로 활용됩니다. +4. **조건 임계값 튜닝:** Phase 4의 `is_hidden` 판정 조건 수(현재 2개 이상) 및 `pass_` 임계값(현재 0.35)은 데이터 누적 후 보정이 필요합니다. + +--- +# 위에 작성된 수식 리뷰 +** 현재 표기되어 있는 가중치들은 모두 이론을 바탕으로 설정한 임의의 값으로, 학습에 따라 얼마든지 변경이 가능하다 ** + +## D(obj) 수식 설계 근거 + +### occlusion_ratio² — 제곱 +"오브젝트가 다른 레이어에 의해 물리적으로 가려진 비율" +가림의 난이도 기여는 비선형입니다. 제곱을 적용하면 고가림 구간에서 기여도가 가속되어 이 비선형성을 근사할 수 있습니다. occlusion_ratio는 0~1 범위의 정규화된 값이므로 제곱해도 범위를 벗어나지 않아 수식 안정성이 유지됩니다. +↳ 가중치 0.25 — "숨어있다"의 가장 직접적인 물리적 원인. 전체 항 중 최고 가중치. + +--- +### 1 / σ_threshold — 역수 +"오브젝트가 배경에 시각적으로 흡수되기 시작하는 블러 민감도" +σ가 낮을수록 찾기 어렵다는 단조 감소 관계를 표현하는 가장 단순한 형태입니다. σ가 블러 강도의 물리적 스케일을 이미 반영하고 있어 역수만으로 충분한 민감도가 확보됩니다. +↳ 가중치 0.20 — 오브젝트 고유의 색상·텍스처 특성에도 영향을 받는 간접 지표. 불확실성을 반영해 occlusion_ratio²보다 낮게 설정. + +--- +### hop / diameter — 비율 +"씬 전체 복잡도 대비 해당 오브젝트의 논리적 매몰 깊이" +절대적 hop 수가 아닌 diameter로 나눈 상대값을 사용해, 씬 구조가 달라도 0~1 사이의 일관된 비교가 가능합니다. +↳ 가중치 0.20 — CLEVR 실험에서 논리 트리 깊이가 AI 난이도에 직접 기여함이 확인됨. occlusion_ratio²와 동등한 영향력으로 판단. + +--- +### degree_norm² — 제곱 +"오브젝트가 씬 내에서 얼마나 많은 관계의 교차점에 위치하는가" +Sort-of-CLEVR에서 참조 객체가 늘어날수록 탐색 난이도가 지수적으로 상승함이 확인되어 선형에서 제곱으로 변경했습니다. 완전한 지수 함수 대신 제곱을 택한 이유는 Semantic Noise가 미반영된 현 상태에서 과도한 가속을 방지하기 위함입니다. +↳ 가중치 0.15 — 차수 자체보다 유사 특징을 가진 이웃과의 연결이 진짜 난이도 원인(GQA). 색상 대비 미반영 상태의 불확실성을 반영해 전체 최저 가중치. 색상 대비 지수 추가 시 0.20~0.25로 상향 예정. + +--- +### (1 - DRR) — 보수 변환 +"블러 환경에서 오브젝트의 시각적 특징 정보가 얼마나 손실되는가" +DRR은 잔존율이므로 1에서 빼서 손실률로 반전합니다. YOLO가 검출에 성공하더라도 DRR이 낮으면 경계가 이미 무너진 상태임을 포착하는 보완 지표입니다. +↳ 가중치 0.20 — 1/σ_threshold와 역할이 일부 겹치나 YOLO의 이진 Pass/Fail에 질적 정보량을 추가하는 보조 지표로 동등 가중치 유지. + +--- +### w_ix · occlusion_ratio · (hop/diameter) — 곱 교호작용 +"물리적 가림과 논리적 매몰이 동시에 심화될 때 발생하는 복합 난이도" +선형 결합만으로는 포착할 수 없는 두 축의 상호작용을 곱으로 명시적으로 모델링합니다. 두 값이 모두 높을 때만 곱이 커지는 특성이 "가림만 심한 경우"와 "둘 다 심한 경우"를 구별합니다. +↳ 가중치 w_ix 미정 (권장 초기값 0.10~0.15) — 데이터 없이 이론적 결정 불가. 초기 씬 100개 이상 생성 후 분포 기반으로 확정 권장. + +--- +### total_score 가중치 구조 +perception × 0.45 + logical × 0.55 +↳ logical에 0.10 우위 — ShapeWorld·CLEVR에서 관계 복잡성이 시각적 노이즈보다 AI에게 더 강한 난이도 허들로 작동. AI 성능 평가 벤치마크라는 목적상 변별력 확보를 위해 logical 우위 부여. +↳ 차이를 0.10으로 제한 — 시각적 숨김이 없는 순수 논리 복잡 씬은 숨은그림찾기로서의 유효성이 낮음. 두 축의 균형이 과도하게 무너지지 않도록 제한. \ No newline at end of file diff --git a/docs/Validator/plan_pipeline.md b/docs/Validator/plan_pipeline.md new file mode 100644 index 0000000..08de70e --- /dev/null +++ b/docs/Validator/plan_pipeline.md @@ -0,0 +1,548 @@ +# Validator 파이프라인 구현 계획 + +> 기반 문서: `instruction_1.md` +> 작성일: 2026-03-03 +> 목표: VRAM 8GB 최적화 4단계 연쇄 검증 파이프라인 구현 + +--- + +## 개요 + +`instruction_1.md`에 정의된 4단계 검증 파이프라인을 기존 Discoverex Engine의 Hexagonal Architecture에 통합 구현한다. +기존 `PerceptionPort`와 `VerificationBundle` 도메인 계약을 확장하여 MobileSAM → Moondream2 → YOLO+CLIP → CANON Assembler 순서로 실행되는 순차 파이프라인을 완성한다. + +--- + +## 현재 아키텍처 연결점 분석 + +### Phase ↔ 기존 구조 대응표 + +| Validator Phase | 기존 구조 대응 | 처리 방식 | +|----------------|---------------|-----------| +| Phase 1 (MobileSAM) | `HiddenRegionPort` | 신규 포트 `PhysicalExtractionPort` 추가, 신규 어댑터 구현 | +| Phase 2 (Moondream2) | `PerceptionPort` (부분) | 신규 포트 `LogicalExtractionPort` 추가, 신규 어댑터 구현 | +| Phase 3 (YOLO+CLIP) | `PerceptionPort` | 신규 포트 `VisualVerificationPort` 추가, 병렬 어댑터 구현 | +| Phase 4 (CANON) | `services/verification.py` | 기존 파일에 함수 3종 추가 확장 | + +### 기존 도메인 타입 재활용 + +| 기존 타입 | 재활용 방식 | +|----------|------------| +| `VerificationBundle` | 스키마 변경 없이 재사용 — `logical`·`perception`·`final` 구조 그대로 | +| `VerificationResult.signals` | `sigma_threshold`, `hop`, `degree_norm`, `DRR` 값을 딕셔너리로 저장 | +| `Region.Geometry` | `occlusion_ratio`, `z_index`, `z_depth_hop`, `neighbor_count` 필드 추가 | +| `Difficulty` | `score` 필드에 `Scene_Difficulty` 수식 결과 저장 | + +--- + +## 파일 변경 로드맵 + +범례: `[NEW]` 신규 생성 · `[MOD]` 기존 파일 수정 + +``` +engine/ +│ +├── src/discoverex/ +│ │ +│ ├── domain/ +│ │ ├── region.py [MOD] Geometry에 z_index, occlusion_ratio, z_depth_hop, neighbor_count 필드 추가 +│ │ ├── verification.py [MOD] VerificationResult.signals 표준 키 docstring 추가 +│ │ └── services/ +│ │ └── verification.py [MOD] resolve_answer(), compute_difficulty(), compute_scene_difficulty(), integrate_verification_v2() 추가 +│ │ +│ ├── models/ +│ │ └── types.py [MOD] PhysicalMetadata, LogicalStructure, VisualVerification, ValidatorInput 데이터클래스 추가 +│ │ +│ ├── application/ +│ │ ├── ports/ +│ │ │ └── models.py [MOD] PhysicalExtractionPort, LogicalExtractionPort, VisualVerificationPort Protocol 추가 +│ │ └── use_cases/ +│ │ └── validator/ +│ │ ├── __init__.py [NEW] 패키지 초기화 +│ │ └── orchestrator.py [NEW] ValidatorOrchestrator — 4단계 순차 실행 + VRAM 바톤 터치 +│ │ +│ ├── adapters/ +│ │ ├── inbound/cli/ +│ │ │ └── main.py [MOD] `validate` CLI 명령어 추가 +│ │ └── outbound/models/ +│ │ ├── dummy.py [MOD] DummyPhysicalExtraction, DummyLogicalExtraction, DummyVisualVerification 추가 +│ │ ├── hf_mobilesam.py [NEW] MobileSAMAdapter — Phase 1 물리 메타데이터 추출 +│ │ ├── hf_moondream2.py [NEW] Moondream2Adapter — Phase 2 논리 관계 추출 (4-bit 양자화) +│ │ └── hf_yolo_clip.py [NEW] YoloCLIPAdapter — Phase 3 YOLO+CLIP 병렬 시각 검증 +│ │ +│ ├── bootstrap/ +│ │ └── factory.py [MOD] Validator용 포트 바인딩 추가 (build_validator_context) +│ │ +│ └── config/ +│ └── schema.py [MOD] ValidatorModelsConfig, ValidatorThresholdsConfig Pydantic 모델 추가 +│ +├── conf/ +│ ├── validator.yaml [NEW] Validator 파이프라인 Hydra 진입점 설정 +│ └── models/ +│ ├── physical_extraction/ +│ │ └── mobilesam.yaml [NEW] MobileSAM 모델 설정 +│ ├── logical_extraction/ +│ │ └── moondream2.yaml [NEW] Moondream2 4-bit 양자화 설정 +│ └── visual_verification/ +│ └── yolo_clip.yaml [NEW] YOLOv10-N + CLIP 병렬 설정 (max 4GB) +│ +└── tests/ + ├── test_validator_pipeline_smoke.py [NEW] Dummy 어댑터 기반 전체 파이프라인 E2E 테스트 + └── test_validator_scoring.py [NEW] D(obj) 수식, is_hidden 판정, total_score 임계값 단위 테스트 +``` + +### 변경 규모 요약 + +| 구분 | 수량 | 대상 | +|------|------|------| +| **신규 생성** | **9개** | `validator/orchestrator.py`, `hf_mobilesam.py`, `hf_moondream2.py`, `hf_yolo_clip.py`, `validator.yaml`, `mobilesam.yaml`, `moondream2.yaml`, `yolo_clip.yaml`, 테스트 2개 | +| **기존 수정** | **8개** | `region.py`, `verification.py`, `services/verification.py`, `types.py`, `ports/models.py`, `dummy.py`, `main.py`, `schema.py` + `factory.py` | +| **변경 없음** | — | `scene.py`, `goal.py`, `storage.py`, `tracking.py`, `delivery/`, `orchestrator/`, `infra/` 등 기존 파이프라인 전체 | + +--- + +## 구현 계획 + +### 1단계: 도메인 모델 확장 + +**파일:** `src/discoverex/domain/region.py` + +`Geometry` 클래스에 물리적 메타데이터 필드 추가: +```python +# 추가할 필드 +z_index: int = 0 # 레이어 Z-order +occlusion_ratio: float = 0.0 # 가림 비율 (0~1) +z_depth_hop: int = 0 # 레이어 깊이 (Shortest Path) +neighbor_count: int = 0 # 군집 반경 내 인접 객체 수 +euclidean_distances: list[float] = [] # 다른 객체까지의 유클리드 거리 +``` + +**파일:** `src/discoverex/domain/verification.py` + +`VerificationResult.signals`에 표준 키 정의 추가 (docstring 수준): +``` +signals 표준 키: + perception: sigma_threshold, detail_retention_rate, occlusion_ratio + logical: hop, diameter, degree, degree_norm + final: is_hidden, difficulty_score, pass_reason +``` + +--- + +### 2단계: 모델 타입 정의 + +**파일:** `src/discoverex/models/types.py` + +각 Phase 입출력 타입 추가: + +```python +# Phase 1 출력 +@dataclass +class PhysicalMetadata: + regions: list[dict] # bbox, center, area per object + occlusion_map: dict # {obj_id: occlusion_ratio} + z_index_map: dict # {obj_id: z_index} + z_depth_hop_map: dict # {obj_id: hop} + cluster_density_map: dict # {obj_id: neighbor_count} + euclidean_distance_map: dict # {obj_id: [distances]} + +# Phase 2 출력 +@dataclass +class LogicalStructure: + relations: list[dict] # [{subject, predicate, object}] + degree_map: dict # {obj_id: degree} + hop_map: dict # {obj_id: hop from root} + diameter: float # 그래프 직경 + +# Phase 3 출력 +@dataclass +class VisualVerification: + sigma_threshold_map: dict # {obj_id: sigma_threshold} + detail_retention_rate_map: dict # {obj_id: DRR} + +# Phase 4 입력 집계 +@dataclass +class ValidatorInput: + physical: PhysicalMetadata + logical: LogicalStructure + visual: VisualVerification +``` + +--- + +### 3단계: 포트(인터페이스) 정의 + +**파일:** `src/discoverex/application/ports/models.py` + +신규 포트 추가: + +```python +class PhysicalExtractionPort(Protocol): + """Phase 1: MobileSAM 기반 물리 메타데이터 추출 포트""" + def load(self, handle: ModelHandle) -> None: ... + def extract(self, composite_image: Path, object_layers: list[Path]) -> PhysicalMetadata: ... + def unload(self) -> None: ... + +class LogicalExtractionPort(Protocol): + """Phase 2: Moondream2 기반 논리 관계 추출 포트""" + def load(self, handle: ModelHandle) -> None: ... + def extract(self, composite_image: Path, physical: PhysicalMetadata) -> LogicalStructure: ... + def unload(self) -> None: ... + +class VisualVerificationPort(Protocol): + """Phase 3: YOLO+CLIP 병렬 시각 난이도 검증 포트""" + def load(self, handle: ModelHandle) -> None: ... + def verify(self, composite_image: Path, sigma_levels: list[float]) -> VisualVerification: ... + def unload(self) -> None: ... +``` + +--- + +### 4단계: 어댑터 구현 + +**경로:** `src/discoverex/adapters/outbound/models/` + +#### Phase 1 어댑터: `hf_mobilesam.py` + +``` +MobileSAMAdapter(PhysicalExtractionPort) +├── load(): MobileSAM 모델 로드 (VRAM) +├── extract(): +│ ├── [Pre-processing] 레이어 vs composite 픽셀 비교 → z_index, occlusion_ratio, z_depth_hop +│ ├── MobileSAM segmentation → 정밀 Mask 생성 +│ ├── Mask → bbox, center, area 산출 +│ ├── 유클리드 거리 + 군집 밀집도 계산 +│ └── 결과 병합 → PhysicalMetadata +└── unload(): del model + torch.cuda.empty_cache() +``` + +#### Phase 2 어댑터: `hf_moondream2.py` + +``` +Moondream2Adapter(LogicalExtractionPort) +├── load(): Moondream2 4-bit 양자화 로드 (VRAM) +├── extract(): +│ ├── Context-Aware Prompt 구성 (Phase 1 좌표 주입) +│ ├── VLM 추론 → 관계 JSON 추출 +│ └── NetworkX 그래프 연산 → degree, hop, diameter +└── unload(): del model + torch.cuda.empty_cache() +``` + +#### Phase 3 어댑터: `hf_yolo_clip.py` + +``` +YoloCLIPAdapter(VisualVerificationPort) +├── load(): YOLOv10-N + CLIP ViT-B/32 병렬 로드 (합계 4GB 미만) +├── verify(): +│ ├── 블러 이미지 배치 생성 (σ = 1, 2, 4, 8, 16) +│ ├── [YOLO Branch] 객체별 소실 임계 블러레벨 (sigma_threshold) 추적 +│ ├── [CLIP Branch] 원본 대비 코사인 유사도 → DRR 산출 +│ └── 통합 → VisualVerification +└── unload(): del yolo_model, clip_model + torch.cuda.empty_cache() +``` + +--- + +### 5단계: 유스케이스 - Validator 오케스트레이터 + +**파일:** `src/discoverex/application/use_cases/validator/orchestrator.py` + +```python +class ValidatorOrchestrator: + """ + 4단계 순차 실행 + VRAM 바톤 터치 전략 + 각 Phase는 독립 함수로 격리, JSON 구조체로만 데이터 전달 + """ + + def run(self, composite_image: Path, object_layers: list[Path]) -> VerificationBundle: + # Phase 1: 물리 데이터 추출 + physical = self._run_phase1(composite_image, object_layers) + + # Phase 2: 논리 관계 추출 (Phase 1 좌표 주입) + logical_struct = self._run_phase2(composite_image, physical) + + # Phase 3: 시각적 난이도 검증 + visual = self._run_phase3(composite_image) + + # Phase 4: 정답 판정 + CANON 조립 (순수 연산) + return self._run_phase4(physical, logical_struct, visual) + + def _run_phase1(self, ...) -> PhysicalMetadata: + self.physical_port.load(self.physical_handle) + result = self.physical_port.extract(composite_image, object_layers) + self.physical_port.unload() # VRAM 초기화 + return result + + def _run_phase2(self, ...) -> LogicalStructure: + self.logical_port.load(self.logical_handle) + result = self.logical_port.extract(composite_image, physical) + self.logical_port.unload() # VRAM 초기화 + return result + + def _run_phase3(self, ...) -> VisualVerification: + self.visual_port.load(self.visual_handle) + result = self.visual_port.verify(composite_image, sigma_levels=[1, 2, 4, 8, 16]) + self.visual_port.unload() # VRAM 종료 + return result + + def _run_phase4(self, ...) -> VerificationBundle: + # Answer Resolver: 2개 이상 조건 충족 시 정답 오브젝트 확정 + # 난이도 산출: D(obj) 수식 적용 + # VerificationBundle 집계: perception × 0.45 + logical × 0.55 + ... +``` + +--- + +### 6단계: Phase 4 도메인 서비스 확장 + +**파일:** `src/discoverex/domain/services/verification.py` + +#### 6-1. Answer Resolver (`is_hidden` 판정) + +```python +def resolve_answer(obj_metrics: dict) -> bool: + """ + 조건 2개 이상 충족 시 정답 오브젝트로 판정 + """ + conditions = [ + obj_metrics["occlusion_ratio"] > 0.3, + obj_metrics["sigma_threshold"] <= 4, + obj_metrics["degree"] >= 3, + obj_metrics["z_depth_hop"] >= 2, + obj_metrics["neighbor_count"] >= 3, + ] + return sum(conditions) >= 2 +``` + +#### 6-2. 난이도 산출 (`D(obj)` 수식) + +```python +def compute_difficulty(obj_metrics: dict, w_ix: float = 0.10) -> float: + """ + D(obj) = 0.25 · occlusion_ratio² + + 0.20 · (1 / σ_threshold) + + 0.20 · (hop / diameter) + + 0.15 · degree_norm² + + 0.20 · (1 - DRR) + + w_ix · occlusion_ratio · (hop / diameter) + """ + occlusion = obj_metrics["occlusion_ratio"] + sigma = obj_metrics["sigma_threshold"] + hop = obj_metrics["hop"] + diameter = obj_metrics["diameter"] + degree_n = obj_metrics["degree_norm"] + drr = obj_metrics["detail_retention_rate"] + + return ( + 0.25 * occlusion ** 2 + + 0.20 * (1 / sigma) + + 0.20 * (hop / diameter) + + 0.15 * degree_n ** 2 + + 0.20 * (1 - drr) + + w_ix * occlusion * (hop / diameter) + ) + +def compute_scene_difficulty(answer_objs: list[dict]) -> float: + """ + Scene_Difficulty = (1 / |answer|) · Σ D(obj) + """ + if not answer_objs: + return 0.0 + return sum(compute_difficulty(obj) for obj in answer_objs) / len(answer_objs) +``` + +#### 6-3. VerificationBundle 집계 + +```python +def integrate_verification_v2( + physical: PhysicalMetadata, + logical_struct: LogicalStructure, + visual: VisualVerification, + obj_id: str, +) -> VerificationBundle: + """ + perception = 0.20 · (1 / σ) + 0.20 · (1 - DRR) + logical = 0.20 · (hop / diameter) + 0.15 · degree_norm² + total = perception × 0.45 + logical × 0.55 + pass = total_score >= 0.35 + """ + sigma = visual.sigma_threshold_map[obj_id] + drr = visual.detail_retention_rate_map[obj_id] + hop = logical_struct.hop_map[obj_id] + diam = logical_struct.diameter + deg_n = logical_struct.degree_map[obj_id] / max_degree # 정규화 + + perception = 0.20 * (1 / sigma) + 0.20 * (1 - drr) + logical = 0.20 * (hop / diam) + 0.15 * deg_n ** 2 + total = perception * 0.45 + logical * 0.55 + + return VerificationBundle( + logical=VerificationResult(score=logical, pass_=total >= 0.35, signals={...}), + perception=VerificationResult(score=perception, pass_=total >= 0.35, signals={...}), + final=FinalVerification( + total_score=total, + pass_=total >= 0.35, + reason=None if total >= 0.35 else "difficulty_too_low", + ), + ) +``` + +--- + +### 7단계: 설정 확장 + +#### 신규 Hydra 설정 파일 + +**`conf/models/physical_extraction/mobilesam.yaml`** +```yaml +_target_: discoverex.adapters.outbound.models.hf_mobilesam.MobileSAMAdapter +model_id: "ChaoningZhang/MobileSAM" +device: cuda +dtype: float16 +``` + +**`conf/models/logical_extraction/moondream2.yaml`** +```yaml +_target_: discoverex.adapters.outbound.models.hf_moondream2.Moondream2Adapter +model_id: "vikhyat/moondream2" +quantization: 4bit +device: cuda +``` + +**`conf/models/visual_verification/yolo_clip.yaml`** +```yaml +_target_: discoverex.adapters.outbound.models.hf_yolo_clip.YoloCLIPAdapter +yolo_model_id: "THU-MIG/yolov10-n" +clip_model_id: "openai/clip-vit-base-patch32" +sigma_levels: [1, 2, 4, 8, 16] +device: cuda +max_vram_gb: 4.0 +``` + +**`conf/validator.yaml`** (신규 파이프라인 진입점) +```yaml +defaults: + - models/physical_extraction: mobilesam + - models/logical_extraction: moondream2 + - models/visual_verification: yolo_clip + - adapters/artifact_store: local + - adapters/metadata_store: local_json + - adapters/tracker: mlflow_file + - runtime/model_runtime: gpu + - runtime/env: default + +thresholds: + is_hidden_min_conditions: 2 + pass_threshold: 0.35 + w_ix: 0.10 +``` + +#### `config/schema.py` 확장 + +```python +class ValidatorModelsConfig(BaseModel): + physical_extraction: HydraComponentConfig + logical_extraction: HydraComponentConfig + visual_verification: HydraComponentConfig + +class ValidatorThresholdsConfig(BaseModel): + is_hidden_min_conditions: int = 2 + pass_threshold: float = 0.35 + w_ix: float = 0.10 # 교호작용 가중치 (초기값, 데이터 누적 후 조정) +``` + +--- + +### 8단계: CLI 통합 + +**파일:** `src/discoverex/adapters/inbound/cli/main.py` + +신규 명령어 추가: +```python +@app.command("validate") +def validate_cmd( + composite_image: Path = typer.Argument(...), + object_layers: list[Path] = typer.Option(...), + config_name: str = typer.Option("validator"), +) -> None: + """ + 4단계 Validator 파이프라인 실행 + 출력: {"scene_id", "status", "verification_bundle", "scene_json"} + """ +``` + +--- + +## 구현 순서 (우선순위) + +| 순서 | 작업 | 파일 | +|------|------|------| +| 1 | 도메인 모델 확장 (`Geometry` 필드 추가) | `domain/region.py` | +| 2 | 모델 타입 정의 추가 | `models/types.py` | +| 3 | 신규 포트 인터페이스 정의 | `application/ports/models.py` | +| 4 | Phase 4 도메인 서비스 구현 | `domain/services/verification.py` | +| 5 | Validator 오케스트레이터 스켈레톤 | `application/use_cases/validator/orchestrator.py` | +| 6 | Dummy 어댑터 구현 (테스트용) | `adapters/outbound/models/dummy.py` 확장 | +| 7 | 설정 스키마 및 Hydra YAML 추가 | `config/schema.py`, `conf/validator.yaml` | +| 8 | CLI 명령어 통합 | `adapters/inbound/cli/main.py` | +| 9 | MobileSAM 어댑터 구현 | `adapters/outbound/models/hf_mobilesam.py` | +| 10 | Moondream2 어댑터 구현 | `adapters/outbound/models/hf_moondream2.py` | +| 11 | YOLO+CLIP 병렬 어댑터 구현 | `adapters/outbound/models/hf_yolo_clip.py` | +| 12 | 통합 테스트 작성 | `tests/test_validator_pipeline_smoke.py` | + +--- + +## 주요 설계 결정 + +### VRAM 관리 전략 +- 각 Phase를 독립 함수로 격리: `load() → extract() → unload()` 패턴 +- `unload()`는 항상 `del model + torch.cuda.empty_cache()` 순서로 호출 +- Phase 3만 예외: YOLO + CLIP 동시 로드하되 합계 4GB 미만 유지 + +### 데이터 전달 방식 +- Phase 간 데이터는 순수 Python 데이터클래스(JSON 직렬화 가능)로만 전달 +- VRAM 텐서를 Phase 간에 공유하지 않음 (메모리 바톤 터치 원칙) +- Phase 2는 Phase 1의 좌표를 프롬프트로 주입 (Context-Aware Prompting) + +### 기존 계약 준수 +- `VerificationBundle` 스키마 변경 없이 재사용 (신호값을 `signals` 딕셔너리에 저장) +- `Difficulty` 도메인 타입에 `score` 필드로 `Scene_Difficulty` 값 저장 +- Hexagonal 경계 규칙 준수: 오케스트레이터는 포트만 의존 + +### 임계값 조정 계획 +- `w_ix` (교호작용 가중치): 초기값 0.10, 씬 100개 이상 생성 후 분포 기반 조정 +- `pass_threshold` (0.35): 데이터 누적 후 보정 필요 +- `is_hidden_min_conditions` (2개): 초기 운영 후 false positive/negative 분석 후 조정 + +--- + +## 테스트 전략 + +### Dummy 어댑터 활용 (VRAM 없이 테스트) +```python +# tests/test_validator_pipeline_smoke.py +def test_validator_orchestrator_dummy(): + """Dummy 어댑터로 전체 4단계 파이프라인 실행 검증""" + orchestrator = ValidatorOrchestrator( + physical_port=DummyPhysicalExtraction(), + logical_port=DummyLogicalExtraction(), + visual_port=DummyVisualVerification(), + ) + bundle = orchestrator.run(composite_image=..., object_layers=[...]) + assert bundle.final.pass_ is True or False # 결과 구조 검증 + assert 0.0 <= bundle.final.total_score <= 1.0 +``` + +### 수식 단위 테스트 +```python +# tests/test_validator_scoring.py +def test_compute_difficulty_formula(): + """D(obj) 수식의 경계값 및 정상 동작 검증""" + +def test_integrate_verification_thresholds(): + """total_score >= 0.35 조건 검증""" + +def test_answer_resolver_conditions(): + """is_hidden 판정 조건 2개 이상 충족 로직 검증""" +``` diff --git a/docs/Validator/plan_pipeline_R.md b/docs/Validator/plan_pipeline_R.md new file mode 100644 index 0000000..94e681e --- /dev/null +++ b/docs/Validator/plan_pipeline_R.md @@ -0,0 +1,236 @@ +# Validator 파이프라인 구현 리뷰 보고서 + +> 작성일: 2026-03-04 +> 검토 기준: `instruction_1.md` (설계 명세) + `DIR.md` (아키텍처 문서) +> 테스트 결과: **56 passed / 4 skipped (pre-existing)** + +--- + +## 1. 구현 범위 개요 + +| 구분 | 파일 수 | 주요 내용 | +|------|---------|-----------| +| 신규 생성 | 12개 | 어댑터 3종, 오케스트레이터, 설정 YAML 7개, 테스트 2개 | +| 기존 수정 | 7개 | 포트, 타입, 도메인 서비스, 설정 스키마, CLI, bootstrap, region | +| 버그·갭 수정 | 4개 | mobilesam BFS, moondream2 이미지 입력, yolo_clip IoU+DRR, orchestrator difficulty | + +--- + +## 2. instruction_1.md 대비 상세 검토 + +### Phase 1 — MobileSAM [`hf_mobilesam.py`](../../src/discoverex/adapters/outbound/models/hf_mobilesam.py) + +| 명세 항목 | 구현 상태 | 비고 | +|-----------|-----------|------| +| Pre-processing: 픽셀 비교로 z_index, occlusion_ratio 산출 | ✅ | alpha 채널 비교 | +| z_depth_hop: 레이어 Z-index 트리 Shortest Path | ✅ | NetworkX DiGraph BFS — **수정됨** | +| 정밀 Mask 생성 (MobileSAM) | ✅ (간소화) | 오브젝트 레이어가 이미 분리된 상태이므로 alpha 채널이 사실상 mask. predictor는 set_image까지 호출되며 향후 point prompt 확장 가능 | +| Bounding box, 중심점, 면적 산출 | ✅ | 정규화 좌표 (÷ w, ÷ h) | +| 유클리드 거리 (객체 간 중심점) | ✅ | `math.hypot` | +| 군집 밀집도 (mean_dist × 0.5 반경 내 객체 수) | ✅ | cluster_density_map | +| VRAM 해제: `del model` + `torch.cuda.empty_cache()` | ✅ | `try/finally` 보장 | + +**초기 갭 → 수정:** z_depth_hop이 단순 카운트로 구현되어 있었음. +→ `NetworkX DiGraph`로 픽셀 오버랩 기반 방향 그래프를 구성 후 `shortest_path_length` BFS 적용. + +--- + +### Phase 2 — Moondream2 [`hf_moondream2.py`](../../src/discoverex/adapters/outbound/models/hf_moondream2.py) + +| 명세 항목 | 구현 상태 | 비고 | +|-----------|-----------|------| +| 4-bit 양자화 (BitsAndBytesConfig) | ✅ | `load_in_4bit=True` | +| Context-Aware Prompting (Phase 1 좌표 주입) | ✅ | `_build_prompt()` 에서 bbox 삽입 | +| 이미지 실제 입력 | ✅ | `encode_image()` + `answer_question()` — **수정됨** | +| JSON 관계 파싱 | ✅ | regex + json.loads, 실패 시 빈 리스트 폴백 | +| NetworkX: degree, hop, diameter | ✅ | DiGraph → 최단 경로, undirected diameter | +| VRAM 해제 | ✅ | `try/finally` 보장 | + +**초기 버그 → 수정:** `_query_model(image, prompt)` 시그니처에 image가 있었으나 tokenizer에만 텍스트를 넣고 이미지를 무시. +→ Moondream2 전용 API인 `model.encode_image(image)` → `model.answer_question(enc_image, prompt, tokenizer)` 호출 방식으로 변경. + +--- + +### Phase 3 — YOLOv10-N + CLIP [`hf_yolo_clip.py`](../../src/discoverex/adapters/outbound/models/hf_yolo_clip.py) + +| 명세 항목 | 구현 상태 | 비고 | +|-----------|-----------|------| +| 병렬 로드 (VRAM < 4GB) | ✅ | YOLOv10-N (~8MB) + CLIP ViT-B/32 (~600MB) | +| 블러 세트 σ = 1, 2, 4, 8, 16 | ✅ | `GaussianBlur(radius=σ)` | +| per-object sigma_threshold (YOLO 소실 임계점) | ✅ | IoU ≥ 0.3 박스 매칭 — **수정됨** | +| per-object DRR (CLIP 디테일 잔존율) | ✅ | bbox crop 후 개별 코사인 유사도 — **수정됨** | +| VRAM 해제 | ✅ | `try/finally` 보장 | + +**초기 갭 1 → 수정:** sigma_threshold가 인덱스 기반(`i >= n_detected`)으로 할당되어 탐지 순서가 바뀌면 오귀속. +→ baseline bbox ↔ detected bbox 간 IoU(≥0.3) 매칭으로 per-object 안정적 매핑. + +**초기 갭 2 → 수정:** DRR이 씬 전체 단위 하나의 값을 모든 객체에 동일하게 할당. +→ 각 객체의 baseline bbox로 원본/max-blur 이미지를 crop 후 개별 CLIP 피처 추출 → per-object 코사인 유사도 계산. + +**추가:** `_iou()` 헬퍼 함수 (모듈 레벨 순수 함수) 신규 작성. + +--- + +### Phase 4 — 순수 연산 [`orchestrator.py`](../../src/discoverex/application/use_cases/validator/orchestrator.py) + [`services/verification.py`](../../src/discoverex/domain/services/verification.py) + +| 명세 항목 | 구현 상태 | 비고 | +|-----------|-----------|------| +| `resolve_answer`: 5개 조건 중 2개 이상 | ✅ | occlusion_ratio>0.3 / σ≤4 / degree≥3 / z_depth_hop≥2 / neighbor_count≥3 | +| `compute_difficulty`: D(obj) 6항 수식 | ✅ | 가중치 0.25/0.20/0.20/0.15/0.20/w_ix 정확 반영 | +| `compute_scene_difficulty`: (1/\|answer\|)·Σ D(obj) | ✅ | answer 없으면 0.0 반환 | +| `integrate_verification_v2`: perception/logical/total | ✅ | 수식 완전 일치 | +| `total_score = perception×0.45 + logical×0.55` | ✅ | | +| `pass = total_score >= 0.35` | ✅ | | +| `failure_reason = "difficulty_too_low"` | ✅ | | +| `scene_difficulty` 출력 포함 | ✅ | `logical.signals["scene_difficulty"]` — **수정됨** | + +**초기 갭 → 수정:** `difficulty = compute_scene_difficulty(...)` 계산은 됐으나 `VerificationBundle`에 포함되지 않아 값이 버려졌음. +→ `logical.signals["scene_difficulty"]` 에 저장하여 출력에 포함. + +--- + +## 3. 아키텍처 정합성 검토 (Hexagonal Architecture) + +``` +[CLI: validate] ──→ build_validator_context() ──→ ValidatorOrchestrator + │ + ┌──────────────────────────────────┤ + │ VRAM 바톤 터치 전략 │ + ├── Phase 1: PhysicalExtractionPort │ + │ load → extract → unload (try/finally) + ├── Phase 2: LogicalExtractionPort │ + │ load → extract → unload (try/finally) + ├── Phase 3: VisualVerificationPort │ + │ load → verify → unload (try/finally) + └── Phase 4: 순수 연산 (VRAM 없음) │ + │ + VerificationBundle + └── logical.signals["scene_difficulty"] ← 수정됨 +``` + +| 원칙 | 확인 결과 | +|------|-----------| +| 포트 순수성 (Protocol 기반 인터페이스) | ✅ `PhysicalExtractionPort`, `LogicalExtractionPort`, `VisualVerificationPort` | +| 어댑터 분리 (도메인 레이어 외부 의존 없음) | ✅ 어댑터에서만 `torch`, `PIL`, `networkx` 임포트 | +| 도메인 서비스 순수성 (dict 기반 입력) | ✅ `resolve_answer`, `compute_difficulty` 등은 외부 타입 미의존 | +| VRAM 격리 보장 | ✅ `try/finally` 패턴으로 예외 시에도 해제 | +| DI Composition Root | ✅ `build_validator_context()` 에서 모든 어댑터 주입 | +| 테스트 가능성 | ✅ Dummy 어댑터 3종으로 GPU 없이 테스트 가능 | + +--- + +## 4. 의존성 관리 + +| 패키지 | 상태 | 설치 방법 | +|--------|------|-----------| +| `ultralytics` | pyproject.toml `[validator]` 그룹 | `uv sync --extra validator` | +| `bitsandbytes` | pyproject.toml `[validator]` 그룹 | 동상 | +| `networkx` | pyproject.toml `[validator]` 그룹 | 동상 | +| `pillow`, `numpy` | pyproject.toml `[validator]` 그룹 | 동상 | +| `mobile-sam` | **PyPI 미등록** | `pip install git+https://github.com/ChaoningZhang/MobileSAM.git` | + +> `mobile-sam`은 PyPI 레지스트리에 없어 `uv lock` 시 해결 불가. pyproject.toml에 주석으로 안내. + +--- + +## 5. 테스트 커버리지 + +### `test_validator_scoring.py` — 순수 수식 단위 테스트 (21개) + +| 테스트 클래스 | 케이스 | 검증 내용 | +|--------------|--------|-----------| +| `TestResolveAnswer` | 6개 | 2조건 충족, 1조건 불충족, 전체 충족, 빈 딕셔너리, occlusion 경계값, sigma 경계값 | +| `TestComputeDifficulty` | 5개 | 0값 비음수, high>low, 기본값, sigma=0 가드, diameter=0 가드 | +| `TestComputeSceneDifficulty` | 3개 | 빈 리스트=0.0, 단일=compute_difficulty와 동일, 다수=평균 | +| `TestIntegrateVerificationV2` | 5개 | 반환 타입, 임계값 초과, 임계값 미달, 비음수, 가중합 수식 검증 | + +> `test_high_difficulty_passes_threshold`: 수정한 수식(perception×0.45 + logical×0.55)을 직접 계산하면 원래 테스트 데이터(degree_norm=0.8)로는 0.3338로 임계값 미달. `degree_norm=1.0, drr=0.0`으로 정정하여 실제 total=0.3725 확보. 수식 근거 주석 추가. + +### `test_validator_pipeline_smoke.py` — E2E 스모크 테스트 (5개) + +| 테스트 | 검증 내용 | +|--------|-----------| +| `test_run_returns_verification_bundle` | 반환 타입이 `VerificationBundle` | +| `test_total_score_in_range` | `0.0 ≤ total_score ≤ 2.0` | +| `test_pass_field_is_bool` | `bundle.final.pass_`가 bool | +| `test_failure_reason_empty_when_pass` | pass 시 reason 비어있음, fail 시 비어있지 않음 | +| `test_signals_contain_expected_keys` | `answer_obj_count`, `sigma_threshold_map`, `detail_retention_rate_map` 포함 여부 | + +--- + +## 6. 미구현 항목 및 이유 + +| 항목 | 상태 | 이유 | +|------|------|------| +| Phase 4 CANON scene.json 조립 | 미구현 | 범위 외. instruction_1.md의 "Scene 조립"은 기존 `gen_verify` 파이프라인의 책임. Validator는 `VerificationBundle` 반환까지가 경계 | +| MobileSAM predictor 실제 마스크 생성 호출 | 간소화 | 입력이 이미 분리된 오브젝트 레이어(obj_*.png)이므로 alpha 채널이 곧 마스크. `predictor.set_image()`까지 호출하여 향후 point/box prompt 확장성 유지 | +| Moondream2 multimodal 입력 프로세서 분리 | 미분리 | Moondream2는 `encode_image` + `answer_question` API가 내부적으로 이미지 프로세싱 처리. 별도 `AutoImageProcessor` 불필요 | + +--- + +## 7. 진행 이유 요약 + +### 왜 z_depth_hop을 BFS로 변경했는가 +instruction_1.md는 *"레이어 Z-index 트리 상의 Shortest Path(Hop)를 통해 매몰 깊이 산출"*을 명시한다. 단순 카운트(`sum(1 for j in range(i+1, n) if occlusion>0)`)는 레이어 간 실제 픽셀 중첩 관계를 무시하고 Z-order 인덱스만 사용하므로 설계 의도와 다르다. 픽셀 오버랩 기반 방향 그래프를 구성 후 BFS 최단 경로를 산출해야 "논리적 깊이"를 올바르게 표현할 수 있다. + +### 왜 Moondream2에 이미지를 실제로 넘겨야 했는가 +Phase 2는 "Context-Aware Prompting"을 통해 *이미지를 보면서* 관계를 추론하는 VLM이 핵심이다. 텍스트만 넘기면 모델은 좌표 문자열만 보고 추론하게 되어 시각적 공간 관계 파악이 불가능해진다. `encode_image()` → `answer_question()` 는 Moondream2의 multimodal 특성을 활용하는 유일한 경로다. + +### 왜 DRR을 per-object bbox crop으로 변경했는가 +씬 전체 이미지의 CLIP 유사도는 배경과 여러 객체가 혼합된 값이므로 특정 객체의 디테일 보존률을 측정하지 못한다. instruction_1.md는 *"객체 별 가할 수 있는 블러 수준"* 및 *"디테일 잔존율을 수치화"*를 명시하며, 이는 각 객체 단위 측정을 의미한다. bbox crop으로 해당 객체 영역만 잘라낸 후 CLIP 유사도를 산출해야 객체 고유의 질감·경계 정보 손실도를 측정할 수 있다. + +### 왜 sigma_threshold를 IoU 매칭으로 변경했는가 +YOLO 탐지 결과는 인덱스가 고정되지 않는다. 블러 수준이 바뀌면 탐지된 박스의 순서와 개수가 달라지므로 `i >= n_detected` 인덱스 비교는 엉뚱한 객체를 소실된 것으로 표시할 수 있다. IoU ≥ 0.3 기준의 bbox 매칭은 baseline에서 탐지된 특정 객체가 블러 후에도 같은 위치에서 탐지되는지를 공간 기반으로 정확히 추적한다. + +### 왜 scene_difficulty를 signals에 포함했는가 +`VerificationBundle`은 `perception`, `logical`, `final` 세 구조체로 구성된 도메인 계약이다. `FinalVerification`에 필드를 추가하면 기존 도메인 타입을 변경해야 하고 하위 호환성이 깨진다. `logical.signals`는 `dict[str, Any]`로 정의되어 있어 확장에 열려 있으며, `scene_difficulty`는 논리 분석의 집계 결과이므로 `logical` 그룹에 위치하는 것이 의미상 적절하다. + +--- + +## 8. 전체 파일 변경 목록 + +### 신규 생성 (12개) +| 파일 | 내용 | +|------|------| +| `src/discoverex/adapters/outbound/models/hf_mobilesam.py` | Phase 1 어댑터 | +| `src/discoverex/adapters/outbound/models/hf_moondream2.py` | Phase 2 어댑터 | +| `src/discoverex/adapters/outbound/models/hf_yolo_clip.py` | Phase 3 어댑터 | +| `src/discoverex/application/use_cases/validator/__init__.py` | ValidatorOrchestrator export | +| `src/discoverex/application/use_cases/validator/orchestrator.py` | 4단계 파이프라인 오케스트레이터 | +| `conf/validator.yaml` | Validator Hydra 진입점 | +| `conf/models/physical_extraction/mobilesam.yaml` | MobileSAM 모델 설정 | +| `conf/models/physical_extraction/dummy.yaml` | 테스트용 설정 | +| `conf/models/logical_extraction/moondream2.yaml` | Moondream2 모델 설정 | +| `conf/models/logical_extraction/dummy.yaml` | 테스트용 설정 | +| `conf/models/visual_verification/yolo_clip.yaml` | YOLO+CLIP 모델 설정 | +| `conf/models/visual_verification/dummy.yaml` | 테스트용 설정 | +| `tests/test_validator_pipeline_smoke.py` | E2E 스모크 테스트 5개 | +| `tests/test_validator_scoring.py` | 순수 수식 단위 테스트 21개 | + +### 기존 수정 (8개) +| 파일 | 수정 내용 | +|------|-----------| +| `src/discoverex/domain/region.py` | `Geometry`에 z_index, occlusion_ratio, z_depth_hop, neighbor_count, euclidean_distances 추가 | +| `src/discoverex/models/types.py` | `PhysicalMetadata`, `LogicalStructure`, `VisualVerification`, `ValidatorInput` 추가 | +| `src/discoverex/application/ports/models.py` | `PhysicalExtractionPort`, `LogicalExtractionPort`, `VisualVerificationPort` 추가 | +| `src/discoverex/domain/services/verification.py` | `resolve_answer`, `compute_difficulty`, `compute_scene_difficulty`, `integrate_verification_v2` 추가 | +| `src/discoverex/config/schema.py` | `ValidatorModelsConfig`, `ValidatorThresholdsConfig`, `ValidatorPipelineConfig` 추가 | +| `src/discoverex/config/__init__.py` | Validator 설정 타입 export | +| `src/discoverex/config_loader.py` | `load_validator_config()` 추가 | +| `src/discoverex/bootstrap/factory.py` | `build_validator_context()` 추가 | +| `src/discoverex/bootstrap/__init__.py` | `build_validator_context` export | +| `src/discoverex/adapters/outbound/models/dummy.py` | 3개 Dummy 어댑터 추가 | +| `src/discoverex/adapters/inbound/cli/main.py` | `validate` 명령어 추가 | +| `pyproject.toml` | `[validator]` optional dependency 그룹 추가 | + +### 버그·갭 수정 (4개, 기존 생성 파일 재수정) +| 파일 | 수정 내용 | +|------|-----------| +| `hf_mobilesam.py` | z_depth_hop: 단순 카운트 → NetworkX BFS Shortest Path | +| `hf_moondream2.py` | _query_model: tokenizer-only → encode_image + answer_question | +| `hf_yolo_clip.py` | sigma_threshold: 인덱스 기반 → IoU 기반 박스 매칭 / DRR: 씬 전체 → per-object bbox crop | +| `orchestrator.py` | scene_difficulty: 계산 후 버림 → logical.signals에 포함 | + +--- + +*본 문서는 `plan_pipeline.md` 실행 후 검토 및 수정 작업 전체를 회고한 리뷰 리포트입니다.* diff --git a/docs/Validator/plan_weights.md b/docs/Validator/plan_weights.md new file mode 100644 index 0000000..68d5b61 --- /dev/null +++ b/docs/Validator/plan_weights.md @@ -0,0 +1,410 @@ +# 가중치 변수화 및 학습 루프 계획안 + +## 목표 + +1. `integrate_verification_v2`, `compute_difficulty` 등에 하드코딩된 숫자를 + `ScoringWeights` Pydantic 모델로 집약하고 YAML로 외부 주입 가능하게 한다. +2. 레이블 데이터를 통해 가중치를 실제로 업데이트할 수 있는 **학습 루프**를 추가한다. + 초기값은 코드 기본값 또는 YAML에서 읽고, 학습 후 JSON 파일로 저장·로드한다. +3. 기존 파일과의 **충돌 지점을 모두 식별**하고 수정 방향을 명시한다. + +--- + +## 1. 수식 구조 변경 (정규화 추가) + +### 기존 문제 + +``` +perception = 0.20 × (1/σ) + 0.20 × (1 − DRR) → 최댓값 0.40 +logical = 0.20 × (hop/d) + 0.15 × deg² → 최댓값 0.35 +total = perception × 0.45 + logical × 0.55 → 최댓값 0.3725 +``` + +pass_threshold = 0.35이므로 완벽한 수치여야만 통과. 현실적 "어려운" 장면은 total ≈ 0.20 → 항상 실패. + +### 변경 후 (정규화) + +``` +p_denom = w_σ + w_drr (0이 되면 1e-9로 보호) +perception = (w_σ × (1/σ) + w_drr × (1−DRR)) / p_denom → [0, 1] + +l_denom = w_hop + w_deg +logical = (w_hop × (hop/d) + w_deg × deg²) / l_denom → [0, 1] + +total = perception × w_p + logical × w_l → max = w_p + w_l +``` + +`w_p + w_l = 1.0`이면 total 최댓값도 1.0. + +### 초기 가중치 설계 검증 + +| 시나리오 | σ | DRR | hop/d | deg_norm | total | 판정 | +|---------|---|-----|-------|----------|--------|---------| +| 어려움 | 2 | 0.3 | 0.50 | 0.70 | 0.543 | PASS ✓ | +| 보통 | 4 | 0.5 | 0.25 | 0.50 | 0.306 | fail ✓ | +| 쉬움 | 8 | 0.8 | 0.00 | 0.20 | 0.083 | fail ✓ | +| 더미 | 1 | 0.0 | 1.00 | 1.00 | 1.000 | PASS ✓ | + +초기값: + +``` +# perception sub-score 가중치 (정규화 분모에 사용되므로 비율만 의미 있음) +perception_sigma = 0.50, perception_drr = 0.50 + +# logical sub-score 가중치 +logical_hop = 0.55, logical_degree = 0.45 + +# total 집계 (합이 1.0이어야 total 최댓값 = 1.0) +total_perception = 0.45, total_logical = 0.55 + +# D(obj) 항별 가중치 (정규화 없음, 절댓값 유지) +difficulty_occlusion = 0.25, difficulty_sigma = 0.20 +difficulty_hop = 0.20, difficulty_degree = 0.15 +difficulty_drr = 0.20, difficulty_interaction = 0.10 +``` + +--- + +## 2. 학습 루프 설계 + +### 2-1. 학습이 필요한 이유 + +현재 계획은 숫자를 변수(필드)로만 교체하므로, 실행 중 가중치가 바뀌지 않는다. +학습 루프가 없으면 초기값이 영구히 고정된다. +레이블 데이터(장면의 pass/fail 정답)를 모아 가중치를 업데이트해야 초기값의 한계를 극복할 수 있다. + +### 2-2. 학습 대상 파라미터 + +12개 필드 중 학습에 실제로 의미 있는 것은 **scoring 관련 6개** (integrate_verification_v2에 쓰이는 값): + +``` +perception_sigma, perception_drr +logical_hop, logical_degree +total_perception, total_logical +``` + +difficulty_* 6개는 장면 난이도 진단용이므로 1차 학습 범위에서 제외해도 무방 (옵션). + +### 2-3. 제약 조건 + +| 파라미터 그룹 | 제약 | 이유 | +|-----------------------|-----------------------------------------|-------------------------------------------| +| perception_sigma/drr | 모두 ≥ 0 (비율만 의미, 합 무관) | 수식 내부에서 합으로 정규화 | +| logical_hop/degree | 모두 ≥ 0 (비율만 의미, 합 무관) | 동일 | +| total_perception/logical | 모두 ≥ 0, **합 = 1.0** 강제 | total 최댓값을 1.0으로 유지하기 위함 | + +→ 최적화 시 `total_perception`만 학습하고 `total_logical = 1.0 - total_perception`으로 파생. + +### 2-4. 손실 함수 + +레이블 데이터: `list[tuple[dict, bool]]` — `(obj_metrics, 정답_pass)` + +``` +손실 = hinge loss: + label=True → loss = max(0, threshold + margin - total_score) # 너무 낮으면 패널티 + label=False → loss = max(0, total_score - (threshold - margin)) # 너무 높으면 패널티 + +margin = 0.05 (기본값, 설정 가능) +``` + +binary cross-entropy 대신 hinge loss를 사용하는 이유: +- sigmoid 없이도 수치적으로 안정적 +- threshold 주변 margin만 학습하면 되므로 수렴 빠름 +- GPU 불필요 (scipy.optimize로 CPU만으로 처리) + +### 2-5. 최적화 알고리즘 + +`scipy.optimize.minimize` — Nelder-Mead 방법 + +- 수식이 미분 불가능한 max() 포함 → gradient-free 방법 선택 +- GPU 불필요, 수백~수천 개 장면 처리 가능 +- 파라미터 개수 ≤ 12개이므로 충분히 빠름 + +### 2-6. 학습 흐름 + +``` +레이블 JSONL 파일 + ({"metrics": {...}, "label": true/false} 한 줄씩) + │ + ▼ +WeightFitter.fit(labeled_data, init_weights) + │ scipy Nelder-Mead 최적화 + ▼ +최적화된 ScoringWeights + │ + ▼ +JSON 파일로 저장 (weights.json) + │ + ▼ +다음 실행 시 factory.py 에서 로드 → ValidatorOrchestrator 에 주입 +``` + +### 2-7. 새로 추가할 파일 + +#### `domain/services/weight_fitter.py` + +```python +class WeightFitter: + SCORED_KEYS = [ + "perception_sigma", "perception_drr", + "logical_hop", "logical_degree", + "total_perception", # total_logical = 1.0 - total_perception 으로 파생 + ] + + def fit( + self, + labeled: list[tuple[dict, bool]], + init_weights: ScoringWeights | None = None, + margin: float = 0.05, + pass_threshold: float = 0.35, + ) -> ScoringWeights: + """레이블 데이터로 ScoringWeights 를 최적화해서 반환.""" + ... + + def _loss(x, labeled, margin, threshold) -> float: + """hinge loss 계산.""" + ... +``` + +#### `adapters/inbound/cli/main.py` — 새 커맨드 추가 + +``` +discoverex fit-weights + --labeled-jsonl PATH # {"metrics":{...}, "label": true} 형식 JSONL + --weights-in PATH? # 초기값 JSON (없으면 ScoringWeights 기본값 사용) + --weights-out PATH # 학습된 가중치를 저장할 JSON 경로 + --pass-threshold FLOAT # 기본 0.35 + --margin FLOAT # hinge 마진, 기본 0.05 +``` + +출력: `{"weights_path": "...", "final_loss": 0.012, "n_samples": 120}` + +### 2-8. 가중치 영속성 (저장/로드) + +별도 어댑터 없이 단순 JSON 파일로 처리: + +```python +# 저장 +Path(weights_out).write_text(weights.model_dump_json(indent=2)) + +# 로드 +ScoringWeights.model_validate_json(Path(weights_in).read_text()) +``` + +`ValidatorPipelineConfig` 에 `weights_path: str | None = None` 필드 추가: +- `weights_path`가 지정되면 factory에서 JSON 파일 로드 → YAML `weights:` 섹션 무시 +- 미지정이면 YAML `weights:` 섹션 또는 코드 기본값 사용 + +--- + +## 3. 아키텍처 흐름 (학습 포함) + +``` +[추론 경로] +YAML weights: / weights_path: (weights.json) + │ + ▼ +ValidatorWeightsConfig [config/schema.py] + │ factory: ScoringWeights(**cfg.weights.model_dump()) + │ 또는: ScoringWeights.model_validate_json(weights_path) + ▼ +ScoringWeights → ValidatorOrchestrator → _run_phase4 + │ + ├─► integrate_verification_v2(metrics, threshold, weights) + ├─► compute_difficulty(metrics, weights) + └─► compute_scene_difficulty(objs, weights) + +[학습 경로] +labeled.jsonl + │ + ▼ +fit-weights CLI → WeightFitter.fit(labeled, init_weights) + │ scipy Nelder-Mead + ▼ +weights.json (저장) + │ + ▼ (다음 실행 시 weights_path 로 로드) +ValidatorOrchestrator +``` + +--- + +## 4. 충돌 검토 — 기존 파일과의 불일치 지점 + +소스를 직접 확인한 결과, 아래 지점들이 계획과 충돌한다. + +### 4-1. `tests/test_validator_pipeline_smoke.py:31` + +```python +# 현재 (충돌) +return ValidatorOrchestrator( + ... + pass_threshold=0.35, + w_ix=0.10, # ← orchestrator에서 w_ix 파라미터 제거 예정 +) +``` + +**수정**: `w_ix=0.10` 제거. `scoring_weights=None`으로 두면 기본값 사용. + +### 4-2. `orchestrator.py:139` — weights 미전달 + +```python +# 현재 (충돌) +p_score, l_score, _ = integrate_verification_v2(metrics, self._pass_threshold) + +# 수정 후 +p_score, l_score, _ = integrate_verification_v2(metrics, self._pass_threshold, self._weights) +``` + +### 4-3. `orchestrator.py:150` — total_score 하드코딩 + +```python +# 현재 (충돌) +total_score = avg_perception * 0.45 + avg_logical * 0.55 + +# 수정 후 +w = self._weights +total_score = avg_perception * w.total_perception + avg_logical * w.total_logical +``` + +### 4-4. `orchestrator.py:153` — compute_scene_difficulty 시그니처 불일치 + +```python +# 현재 (충돌) +difficulty = compute_scene_difficulty(answer_obj_metrics, self._w_ix) + +# 수정 후 +difficulty = compute_scene_difficulty(answer_obj_metrics, self._weights) +``` + +### 4-5. `factory.py:109` — w_ix 전달 + +```python +# 현재 (충돌) +return ValidatorOrchestrator( + ... + w_ix=cfg.thresholds.w_ix, # ← 제거 예정 +) + +# 수정 후 +weights = ScoringWeights(**cfg.weights.model_dump()) +return ValidatorOrchestrator( + ... + scoring_weights=weights, +) +``` + +### 4-6. `config/schema.py:101-108` — ValidatorThresholdsConfig.w_ix + +```python +# 현재 +class ValidatorThresholdsConfig(BaseModel): + is_hidden_min_conditions: int = 2 + pass_threshold: float = 0.35 + w_ix: float = 0.10 # ← weights 로 이관 예정, 제거 + + @field_validator("pass_threshold", "w_ix") # ← w_ix 제거 + ... +``` + +**수정**: `w_ix` 필드와 해당 validator에서 `"w_ix"` 제거. + +### 4-7. `conf/validator.yaml:17` — w_ix 항목 + +```yaml +# 현재 (충돌) +thresholds: + is_hidden_min_conditions: 2 + pass_threshold: 0.35 + w_ix: 0.10 # ← 제거 + +# 수정 후: weights: 섹션으로 이동 +weights: + difficulty_interaction: 0.10 + ... +``` + +### 4-8. `tests/test_validator_scoring.py:191` — total_score 하드코딩 + +```python +# 현재 (충돌) +expected_total = p * 0.45 + l * 0.55 + +# 수정 후 +from discoverex.domain.services.verification import ScoringWeights +w = ScoringWeights() +expected_total = p * w.total_perception + l * w.total_logical +``` + +### 4-9. `tests/test_validator_scoring.py:151-154` — 주석 (구 수식) + +```python +# 현재 (주석만 충돌, 테스트 자체는 통과) +# sigma=1 → 0.20*(1/1) + 0.20*(1-0.0) = 0.40 (perception) +# hop=4/diameter=4, degree_norm=1.0 → 0.20*1.0 + 0.15*1.0 = 0.35 (logical) +# total = 0.40*0.45 + 0.35*0.55 = 0.3725 >= 0.35 + +# 수정 후 +# perception = (0.5*(1/1) + 0.5*1.0) / 1.0 = 1.0 +# logical = (0.55*1.0 + 0.45*1.0) / 1.0 = 1.0 +# total = 1.0*0.45 + 1.0*0.55 = 1.0 >= 0.35 +``` + +### 4-10. `tests/test_validator_pipeline_smoke.py:59` — total_score 상한 + +```python +# 현재 +assert 0.0 <= bundle.final.total_score <= 2.0 # 주석: scores can exceed 1.0 + +# 정규화 후 max = 1.0 이므로 상한을 1.0 으로 수정 +assert 0.0 <= bundle.final.total_score <= 1.0 +``` + +### 충돌 요약표 + +| 파일 | 위치 | 충돌 내용 | 수정 방향 | +|------|------|-----------|-----------| +| `test_validator_pipeline_smoke.py` | L31 | `w_ix=0.10` 전달 | 파라미터 제거 | +| `test_validator_pipeline_smoke.py` | L59 | 상한 `<= 2.0` | `<= 1.0` 으로 수정 | +| `orchestrator.py` | L139 | weights 미전달 | weights 파라미터 추가 | +| `orchestrator.py` | L150 | `0.45/0.55` 하드코딩 | `w.total_perception/logical` 사용 | +| `orchestrator.py` | L153 | `self._w_ix` 전달 | `self._weights` 전달 | +| `factory.py` | L109 | `w_ix=...` 전달 | `scoring_weights=...` 로 교체 | +| `config/schema.py` | L101-108 | `w_ix` 필드 존재 | 필드 및 validator에서 제거 | +| `conf/validator.yaml` | L17 | `w_ix: 0.10` | `weights:` 섹션으로 이동 | +| `tests/test_validator_scoring.py` | L191 | `0.45/0.55` 하드코딩 | `ScoringWeights()` 참조로 교체 | +| `tests/test_validator_scoring.py` | L151-154 | 구 수식 주석 | 정규화 수식으로 업데이트 | + +--- + +## 5. 변경 파일 전체 목록 + +### 수정 + +| 파일 | 주요 변경 내용 | +|------|--------------| +| `domain/services/verification.py` | `ScoringWeights` 추가, 3개 함수 시그니처 변경, 정규화 수식 적용 | +| `config/schema.py` | `ValidatorWeightsConfig` 추가, `ValidatorPipelineConfig`에 `weights` 필드 추가, `ValidatorThresholdsConfig.w_ix` 제거 | +| `config/__init__.py` | `ValidatorWeightsConfig` export 추가 | +| `application/use_cases/validator/orchestrator.py` | `w_ix` → `scoring_weights`, `_run_phase4` 내 하드코딩 제거 | +| `bootstrap/factory.py` | `ScoringWeights` 빌드 후 orchestrator에 주입, `weights_path` 로드 로직 추가 | +| `conf/validator.yaml` | `thresholds.w_ix` 제거, `weights:` 섹션 추가, `weights_path:` 항목 추가 (기본 null) | +| `adapters/inbound/cli/main.py` | `fit-weights` 커맨드 추가 | +| `tests/test_validator_scoring.py` | 주석 업데이트, `ScoringWeights` import, `expected_total` 수식 교체 | +| `tests/test_validator_pipeline_smoke.py` | `w_ix` 제거, 상한 1.0으로 수정 | + +### 신규 + +| 파일 | 내용 | +|------|------| +| `domain/services/weight_fitter.py` | `WeightFitter` 클래스 (hinge loss + Nelder-Mead) | + +### 변경 없음 + +| 파일 | 이유 | +|------|------| +| `domain/verification.py` | VerificationBundle/FinalVerification 구조 그대로 | +| `models/types.py` | ValidatorInput, PhysicalMetadata 등 그대로 | +| `adapters/outbound/models/*` | 포트 구현체 그대로 | +| `application/ports/models.py` | 포트 인터페이스 그대로 | +| `domain/services/judgement.py` | validator 미사용 | diff --git a/docs/Validator/plan_weights_R.md b/docs/Validator/plan_weights_R.md new file mode 100644 index 0000000..f9109f2 --- /dev/null +++ b/docs/Validator/plan_weights_R.md @@ -0,0 +1,331 @@ +# Validator 가중치 설계 — 구현 현황 리뷰 (R) + +> 이 문서는 `plan_weights.md`에서 계획한 내용이 실제로 어떻게 구현되었는지, +> 추론(inference)과 학습(training)이 어떻게 분리되어 독립적으로 동작하는지를 +> 실제 코드 기준으로 기록한다. + +--- + +## 1. 파라미터 전체 지도 + +아래 표는 Validator 파이프라인에 존재하는 **모든 조정 가능 수치**를 +성격별로 분류한 것이다. + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ A. 학습 가능 (WeightFitter 최적화 대상) │ +│ 미분 가능 연속 함수 → Nelder-Mead gradient-free 최적화 │ +│ │ +│ PHASE 4 ScoringWeights (engine/src/discoverex/ │ +│ domain/services/verification.py) │ +│ │ +│ scoring 5개 (WeightFitter 최적화): │ +│ perception_sigma = 0.50 (PHASE 3 σ 항 가중치) │ +│ perception_drr = 0.50 (PHASE 3 DRR 항 가중치) │ +│ logical_hop = 0.55 (PHASE 2 hop/d 항 가중치) │ +│ logical_degree = 0.45 (PHASE 2 degree² 항 가중치) │ +│ total_perception = 0.45 (total 집계 가중치) │ +│ [total_logical = 1.0 − total_perception 으로 자동 파생] │ +│ │ +│ difficulty 6개 (현재 init_weights 값 고정, 확장 가능): │ +│ difficulty_occlusion = 0.25 │ +│ difficulty_sigma = 0.20 │ +│ difficulty_hop = 0.20 │ +│ difficulty_degree = 0.15 │ +│ difficulty_drr = 0.20 │ +│ difficulty_interaction = 0.10 │ +├─────────────────────────────────────────────────────────────────────┤ +│ B. 동결(frozen) + YAML 변수화 │ +│ step function 포함 → 미분 불가, WeightFitter 제외 │ +│ YAML 수치 변경으로 수동 실험 가능 │ +│ │ +│ PHASE 1 cluster_radius_factor = 0.5 │ +│ (conf/models/physical_extraction/mobilesam.yaml) │ +│ cluster_radius = mean_dist × factor │ +│ → cluster_density_map (이웃 객체 수) 결정 │ +│ │ +│ PHASE 3 iou_match_threshold = 0.3 │ +│ (conf/models/visual_verification/yolo_clip.yaml) │ +│ IoU < threshold → 객체 사라짐으로 판정 │ +│ → sigma_threshold_map 결정 │ +│ │ +│ PHASE 3 sigma_levels = [1.0, 2.0, 4.0, 8.0, 16.0] │ +│ (conf/models/visual_verification/yolo_clip.yaml) │ +│ 이산 블러 격자 — feature space 자체를 정의 │ +├─────────────────────────────────────────────────────────────────────┤ +│ C. 알고리즘 정의 상수 (변경 불필요) │ +│ 수학적 정의에서 도출 — 튜닝 대상 아님 │ +│ │ +│ PHASE 1 occlusion 판정: alpha > 0 (픽셀 투명도 기준) │ +│ PHASE 2 root 판정: in_degree == 0 (그래프 이론 정의) │ +│ PHASE 2 diameter 하한: max(diameter, 1.0) (0-division 방지) │ +│ PHASE 4 is_hidden_min_conditions = 2 (conf/validator.yaml) │ +│ PHASE 4 pass_threshold = 0.35 (conf/validator.yaml) │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. 추론 파이프라인 — 학습과의 독립성 + +### 2-1. 데이터 흐름 + +``` +이미지 파일 (composite.png + obj_*.png) + │ + ▼ +[PHASE 1] MobileSAMAdapter.extract() + · cluster_radius_factor (frozen, YAML) + → PhysicalMetadata + occlusion_map, z_depth_hop_map, cluster_density_map, + euclidean_distance_map, regions + │ + ▼ (PhysicalMetadata 전달 → bbox로 VLM 프롬프트 구성) +[PHASE 2] Moondream2Adapter.extract(composite, physical) + · 신규 하드코딩 수치 없음 + → LogicalStructure + degree_map, hop_map, diameter, relations + │ + ▼ +[PHASE 3] YoloCLIPAdapter.verify() + · iou_match_threshold (frozen, YAML) + · sigma_levels (frozen, YAML) + → VisualVerification + sigma_threshold_map, detail_retention_rate_map + │ + ▼ +[PHASE 4] 순수 수식 계산 (모델 로드 없음) + · ScoringWeights (학습 가능, YAML 또는 weights.json) + integrate_verification_v2() → perception, logical, total score + compute_scene_difficulty() → difficulty + resolve_answer() → 정답 객체 식별 + → VerificationBundle +``` + +### 2-2. PHASE 2가 PHASE 1 출력을 활용하는 방식 + +```python +# orchestrator.py +physical = self._run_phase1(composite_image, object_layers) +logical = self._run_phase2(composite_image, physical) # physical 주입 +``` + +`Moondream2Adapter._build_prompt(physical)`: +``` +"The scene contains these objects: + obj_0 at bbox [0.12, 0.08, 0.35, 0.42]; + obj_1 at bbox [0.55, 0.20, 0.28, 0.38]. ..." +``` + +PHASE 1의 `regions` (SAM이 추출한 정밀 bbox)가 VLM 프롬프트에 주입되어 +PHASE 2의 공간 관계 추론이 물리적 위치 정보를 기반으로 동작한다. +이후 그래프 계산(degree, hop, diameter)은 전부 CPU에서 수행된다. + +### 2-3. ScoringWeights 주입 경로 + +``` +conf/validator.yaml + weights_path: null ← null이면 weights: 섹션 사용 + weights: + perception_sigma: 0.50 + ... + │ + ▼ +factory.py: _build_scoring_weights(cfg) + if cfg.weights_path: + ScoringWeights.model_validate_json(path.read_text()) # JSON 로드 + else: + ScoringWeights(**cfg.weights.model_dump()) # YAML 로드 + │ + ▼ +ValidatorOrchestrator(scoring_weights=...) + self._weights = scoring_weights or ScoringWeights() + │ + ▼ +_run_phase4() → integrate_verification_v2(metrics, threshold, w) +``` + +### 2-4. ML 폴더와의 의존 관계 + +``` +discoverex/ 패키지 engine/src/ML/ 패키지 +────────────────── ───────────────────── +(추론 코드) (학습 전용 코드) + + ▶ import discoverex.domain.services.verification.ScoringWeights + ▶ import discoverex.domain.services.verification.integrate_verification_v2 + +discoverex/ ─────────────────────────────────────────────────────▶ (import 없음) +``` + +`discoverex/` 패키지 어디에도 `ML` 모듈을 import하는 코드가 없다. +`engine/src/ML/`을 통째로 삭제해도 추론 파이프라인은 영향 없이 동작한다. +두 영역의 교환점은 오직 **`weights.json` 파일**이다. + +--- + +## 3. 학습 파이프라인 — 구현 현황 + +### 3-1. 학습 입력 데이터 + +WeightFitter가 받는 입력은 **이미 추출된 metrics dict + label** 쌍이다. +이미지 자체는 입력으로 들어오지 않는다. + +```jsonl +{"metrics": {"sigma_threshold": 2.0, "detail_retention_rate": 0.3, + "hop": 2, "diameter": 4.0, "degree_norm": 0.7}, "label": true} +{"metrics": {"sigma_threshold": 8.0, "detail_retention_rate": 0.9, + "hop": 0, "diameter": 4.0, "degree_norm": 0.1}, "label": false} +``` + +실제 이미지로 학습 데이터를 만들려면: + +``` +이미지 → PHASE 1~3 실행 → per-object metrics 추출 → label 부착 → JSONL 저장 +``` + +이 변환 단계는 현재 구현에 포함되지 않으며, +`ValidatorOrchestrator.run()`으로 `VerificationBundle`을 얻은 뒤 +그 안의 signals에서 metrics를 수동으로 수집해야 한다. + +### 3-2. 손실 함수 — hinge loss + +``` +label=True → loss = max(0, threshold + margin − score) + (점수가 threshold+margin 미만이면 패널티) + +label=False → loss = max(0, score − (threshold − margin)) + (점수가 threshold−margin 초과이면 패널티) + +평균 loss = Σ loss / N +``` + +- `margin` 기본값 = 0.05 (threshold 양쪽 5% 마진) +- `pass_threshold` 기본값 = 0.35 + +### 3-3. 최적화 알고리즘 + +`scipy.optimize.minimize` — Nelder-Mead (gradient-free) + +선택 이유: +- hinge loss의 `max()` 와 step function들이 미분 불가능한 지점을 포함 +- GPU 불필요, CPU만으로 수렴 (파라미터 5개로 충분히 빠름) +- `total_perception`만 최적화하고 `total_logical = 1 − total_perception`으로 파생 + → total_perception + total_logical = 1.0 제약 자동 충족 + +### 3-4. 학습 흐름 + +``` +labeled.jsonl (PHASE 1~3 결과를 수동 수집한 metrics) + │ + ▼ +fit_weights.py CLI (engine/src/ML/fit_weights.py) + python engine/src/ML/fit_weights.py \ + --labeled-jsonl data/labeled.jsonl \ + --weights-out ml_output/weights.json \ + [--weights-in ml_output/weights.json] # 이어 학습 + [--pass-threshold 0.35] + [--margin 0.05] + [--max-iter 5000] + │ + ▼ +WeightFitter.fit(labeled, init_weights) (engine/src/ML/weight_fitter.py) + scipy Nelder-Mead: x = [perception_sigma, perception_drr, + logical_hop, logical_degree, total_perception] + │ 최적화 완료 + ▼ +ScoringWeights (최적화된 5개 + difficulty_* 6개는 init값 유지) + │ + ▼ +weights.json 저장 +``` + +### 3-5. 학습 결과를 추론에 반영 + +```yaml +# conf/validator.yaml +weights_path: /path/to/ml_output/weights.json # ← 이 줄 하나만 추가 +``` + +이후 `build_validator_context()` 호출 시 `factory._build_scoring_weights()`가 +JSON 파일을 읽어 `ScoringWeights`를 구성하고 `ValidatorOrchestrator`에 주입한다. +YAML의 `weights:` 섹션은 무시된다. + +--- + +## 4. frozen hyperparam 수동 실험 방법 + +동결된 파라미터(B 영역)는 YAML만 수정하면 재실행 시 즉시 반영된다. + +### PHASE 1: cluster_radius_factor 조정 + +```yaml +# conf/models/physical_extraction/mobilesam.yaml +cluster_radius_factor: 0.5 # 기본값 +# cluster_radius_factor: 0.3 # 좁은 클러스터 → 이웃 판정 더 엄격 +# cluster_radius_factor: 0.8 # 넓은 클러스터 → 이웃 판정 더 관대 +``` + +효과: `cluster_density_map` 값이 달라짐 → `resolve_answer()`의 +`neighbor_count >= 3` 조건 충족 여부가 바뀜 → answer 객체 집합이 달라짐. + +### PHASE 3: iou_match_threshold 조정 + +```yaml +# conf/models/visual_verification/yolo_clip.yaml +iou_match_threshold: 0.3 # 기본값 +# iou_match_threshold: 0.5 # 엄격 → 같은 σ에서 더 쉽게 "사라짐"으로 판정 +# iou_match_threshold: 0.1 # 관대 → 더 높은 σ까지 버텨야 사라짐 판정 +``` + +효과: `sigma_threshold_map` 값이 달라짐 → +`sigma` 가 낮을수록 perception score 상승 (1/σ 항). + +### PHASE 3: sigma_levels 조정 + +```yaml +sigma_levels: [1.0, 2.0, 4.0, 8.0, 16.0] # 기본 (5단계) +# sigma_levels: [0.5, 1.0, 2.0, 4.0, 8.0, 16.0] # 세밀한 저σ 구간 추가 +# sigma_levels: [2.0, 8.0, 32.0] # 거친 3단계 +``` + +효과: 측정 격자가 바뀌므로 sigma_threshold가 취할 수 있는 값 집합이 달라짐. + +--- + +## 5. 계획(plan_weights.md) → 구현 대응표 + +| 계획 항목 | 구현 파일 | 상태 | +|----------|----------|------| +| ScoringWeights Pydantic 모델 (12 필드) | `domain/services/verification.py` | ✅ 완료 | +| integrate_verification_v2 정규화 수식 | `domain/services/verification.py` | ✅ 완료 | +| compute_difficulty / compute_scene_difficulty | `domain/services/verification.py` | ✅ 완료 | +| ValidatorWeightsConfig (config 레이어) | `config/schema.py` | ✅ 완료 | +| weights_path 로드 분기 | `bootstrap/factory.py` | ✅ 완료 | +| orchestrator scoring_weights 주입 | `application/use_cases/validator/orchestrator.py` | ✅ 완료 | +| conf/validator.yaml weights 섹션 | `conf/validator.yaml` | ✅ 완료 | +| WeightFitter (hinge loss + Nelder-Mead) | `engine/src/ML/weight_fitter.py` | ✅ 완료 | +| fit_weights.py CLI 스크립트 | `engine/src/ML/fit_weights.py` | ✅ 완료 | +| cluster_radius_factor 변수화 | `hf_mobilesam.py` + `mobilesam.yaml` | ✅ 완료 | +| iou_match_threshold 변수화 | `hf_yolo_clip.py` + `yolo_clip.yaml` | ✅ 완료 | +| sigma_levels 변수화 | `hf_yolo_clip.py` + `yolo_clip.yaml` | ✅ (기존부터) | +| E2E 테스트 (Dummy 어댑터) | `tests/test_validator_e2e.py` | ✅ 완료 | +| 전체 테스트 통과 | — | ✅ 81 passed, 7 skipped | + +### 계획 대비 달라진 점 + +| 계획 | 실제 | 이유 | +|------|------|------| +| `adapters/inbound/cli/main.py`에 fit-weights 커맨드 추가 | `engine/src/ML/fit_weights.py`로 독립 스크립트 분리 | 학습 코드를 추론 엔진과 완전 분리하기 위해 ML 폴더로 이동 | +| `domain/services/weight_fitter.py` 위치 | `engine/src/ML/weight_fitter.py` | 동일 이유 | + +--- + +## 6. 현재 구현의 한계와 향후 확장 가능성 + +| 항목 | 현재 | 향후 가능한 방향 | +|------|------|----------------| +| 학습 입력 | metrics JSONL (수동 수집) | 이미지 → PHASE 1~3 자동 실행 후 JSONL 저장 스크립트 추가 | +| difficulty_* 학습 | 고정 (init_weights 유지) | WeightFitter `_SCORED_KEYS`에 추가하면 즉시 확장 가능 | +| frozen hyperparam 실험 자동화 | YAML 수동 수정 | grid search 스크립트 추가 (cluster_radius_factor × iou_match_threshold) | +| 딥러닝 파라미터 (SAM/Moondream2/YOLO/CLIP) | 미구현 | 별도 fine-tuning 파이프라인 필요 (gradient-based, phase별 labeled dataset) | diff --git "a/docs/Validator/\352\265\254\355\230\204\355\230\204\355\231\251.md" "b/docs/Validator/\352\265\254\355\230\204\355\230\204\355\231\251.md" new file mode 100644 index 0000000..5fa5de1 --- /dev/null +++ "b/docs/Validator/\352\265\254\355\230\204\355\230\204\355\231\251.md" @@ -0,0 +1,292 @@ +# Validator 구현 현황 + +작성일: 2026-03-05 +테스트: 82 passed / 6 skipped + +--- + +## 1. 개요 + +`instruction_1.md`에서 설계한 4-Phase VRAM 최적화 Validator 파이프라인의 현재 구현 상태를 정리한다. +원본 계획(`plan_pipeline.md`, `plan_weights.md`)은 학습 파이프라인을 포함하지 않은 채 작성되었으며, +실제 구현 과정에서 학습 파이프라인(`engine/src/ML/`)과 MVP 데이터 수집 체계가 추가되었다. + +--- + +## 2. 4-Phase 파이프라인 구현 현황 + +### Phase 1 — Physical Metadata (MobileSAM) + +**계획 → 구현 상태: ✅ 완료, 1개 개선** + +| 항목 | 계획 (instruction_1.md) | 구현 | +|------|------------------------|------| +| 오클루전 계산 | alpha 채널 픽셀 비교 | ✅ 레이어 alpha vs composite alpha | +| z_index | 레이어 순서 | ✅ 순서 기반 정수 | +| z_depth_hop | 단순 레이어 거리 | ✅ **개선** — NetworkX DiGraph BFS 최단경로 (plan_pipeline_R.md) | +| 이웃 클러스터링 | 반경 내 객체 수 | ✅ cluster_radius_factor × mean_dist | +| bbox / center | SAM 마스크 기반 | ✅ alpha→bbox 추출 + SAM predictor | +| VRAM 라이프사이클 | load/unload | ✅ try/finally 보장 | + +**동결 하이퍼파라미터 (신규 추가)** + +```yaml +# conf/models/physical_extraction/mobilesam.yaml +cluster_radius_factor: 0.5 # step function → 미분 불가 → YAML 변수화 +``` + +--- + +### Phase 2 — Logical Structure (Moondream2) + +**계획 → 구현 상태: ✅ 완료, 1개 수정** + +| 항목 | 계획 | 구현 | +|------|------|------| +| VLM 관계 추출 | Moondream2 | ✅ encode_image + answer_question | +| scene graph | NetworkX | ✅ degree_map, hop_map, diameter | +| PHASE 1 결과 활용 | physical→logical 전달 | ✅ extract(composite, physical) | +| VRAM 라이프사이클 | load/unload | ✅ try/finally 보장 | + +**계획 대비 수정**: 초기 구현에서 text-only tokenization 사용 → `encode_image()` 방식으로 수정 (plan_pipeline_R.md) + +--- + +### Phase 3 — Visual Verification (YOLOv10 + CLIP) + +**계획 → 구현 상태: ✅ 완료, 2개 수정** + +| 항목 | 계획 | 구현 | +|------|------|------| +| 블러 레벨 테스트 | σ = 1,2,4,8,16 | ✅ YAML `sigma_levels` | +| sigma_threshold 매핑 | 블러 인덱스 기반 | ✅ **수정** — IoU ≥ 0.3 bbox 매칭 (안정성) | +| Detail Retention Rate | 씬 단위 | ✅ **수정** — 객체별 bbox-crop CLIP 유사도 | +| VRAM 라이프사이클 | load/unload | ✅ try/finally 보장 | + +**동결 하이퍼파라미터 (신규 추가)** + +```yaml +# conf/models/visual_verification/yolo_clip.yaml +sigma_levels: [1.0, 2.0, 4.0, 8.0, 16.0] # 이산 격자 → 학습 불가 +iou_match_threshold: 0.3 # step function → 미분 불가 → YAML 변수화 +``` + +--- + +### Phase 4 — Answer Resolution (순수 계산) + +**계획 → 구현 상태: ✅ 완료, 1개 보완** + +| 항목 | 계획 | 구현 | +|------|------|------| +| resolve_answer (5조건 중 2개) | ✅ | `verification.py:resolve_answer()` | +| D(obj) 난이도 공식 | ✅ | `verification.py:compute_difficulty()` | +| 씬 난이도 집계 | ✅ | `verification.py:compute_scene_difficulty()` | +| 정규화 스코어링 | ✅ | `verification.py:integrate_verification_v2()` | +| scene_difficulty 저장 | 계획에 없음 | ✅ **보완** — `logical.signals["scene_difficulty"]` | + +--- + +## 3. 오케스트레이터 / 아키텍처 + +### ValidatorOrchestrator + +``` +orchestrator.py → 4-Phase 순차 실행, VRAM 바통 패스 전략 +``` + +- Phase 간 데이터: Pydantic 모델 직렬화 가능 형태로 전달 +- `BundleStorePort` 인터페이스 통해 결과 영속화 (use case가 직접 파일 쓰기 금지) + +### 헥사고날 아키텍처 준수 + +| 레이어 | 역할 | 파일 | +|--------|------|------| +| Domain | 순수 비즈니스 로직 | `domain/services/verification.py` | +| Application Port | 인터페이스 정의 | `application/ports/models.py` | +| Application Use Case | 4-Phase 오케스트레이션 | `application/use_cases/validator/orchestrator.py` | +| Adapter (inbound) | CLI 진입점 | `adapters/inbound/` | +| Adapter (outbound) | 모델 구현체 | `adapters/outbound/models/hf_*.py` | +| Adapter (outbound) | 데이터 수집 | `adapters/outbound/bundle_store.py` | +| Bootstrap | DI 조합 | `bootstrap/factory.py` | + +--- + +## 4. 학습 파이프라인 (원본 계획에 없던 추가 사항) + +원본 `plan_pipeline.md`와 `plan_weights.md`에는 학습 파이프라인 자체가 기술되어 있지 않았으며, +"파라미터를 학습 가능하게 만들자"는 방향은 `plan_weights.md` 이후 논의에서 추가되었다. + +### 파라미터 3분류 체계 + +| 구역 | 파라미터 | 개수 | 학습 방식 | +|------|---------|------|----------| +| **A — 학습 가능** | ScoringWeights (perception_*, logical_*, total_*, difficulty_*) | 12개 | WeightFitter (Nelder-Mead) | +| **B — 동결 + YAML 변수화** | cluster_radius_factor, iou_match_threshold, sigma_levels | 3개 | 수동 실험 조정 | +| **C — 알고리즘 상수** | alpha>0 임계값, in_degree==0, is_hidden 조건 수(2) 등 | - | 변경 불필요 | + +### WeightFitter 구조 + +``` +engine/src/ML/ +├── weight_fitter.py WeightFitter 클래스 (Nelder-Mead, hinge loss) +├── fit_weights.py CLI 진입점 (JSONL → weights.json) +└── data/ VerificationBundle 저장 디렉터리 (MVP 수집) +``` + +**학습 입력 형식** (JSONL): +```json +{"metrics": {"sigma_threshold": 2.0, "detail_retention_rate": 0.3, + "hop": 2, "diameter": 4.0, "degree_norm": 0.7}, "label": true} +``` + +**추론 ↔ 학습 의존성 분리**: +``` +discoverex/ (추론) ─→ weights.json ←─ ML/ (학습) +``` +`discoverex/` 패키지는 `ML/`을 import하지 않음. 역방향 의존 없음. + +### MVP 데이터 수집 체계 + +운영 중 생성된 모든 `VerificationBundle`을 자동 저장: + +```yaml +# validator.yaml +bundle_store_dir: "engine/src/ML/data" +``` + +저장 형식: `{YYYYMMDD_HHMMSS}_{uuid8}.json` +`label` 필드는 `null`로 초기화 → 이후 사람 판단 또는 룰 기반으로 채워 학습 데이터로 활용. + +--- + +## 5. 충돌 / 불일치 항목 + +### ⚠️ 1. `is_hidden_min_conditions` YAML 값이 실제 미사용 + +**현황**: `validator.yaml` → `schema.py`에 `is_hidden_min_conditions: 2` 필드가 있으나, +실제 판정 로직 `resolve_answer()`는 `>= 2`를 **하드코딩**하고 있어 YAML 값이 무시된다. + +```python +# verification.py — 하드코딩 +return sum(conditions) >= 2 # ← YAML의 is_hidden_min_conditions 미반영 +``` + +**권장 조치**: `ValidatorOrchestrator`에 `min_conditions` 파라미터를 추가하거나, +`resolve_answer()`에 파라미터로 주입하도록 수정 필요. + +--- + +### ⚠️ 2. `pass_threshold` 불일치 (추론 0.23 vs 학습 기본값 0.35) + +**현황**: + +| 위치 | 값 | +|------|-----| +| `validator.yaml` (MVP 운영) | `0.23` | +| `WeightFitter.fit()` 기본값 | `0.35` | +| `fit_weights.py --pass-threshold` 기본값 | `0.35` | +| 원본 `instruction_1.md` 설계값 | `0.35` | + +학습 시 `fit_weights.py --pass-threshold 0.23`을 명시하지 않으면 +운영과 다른 threshold로 학습된 가중치가 생성된다. + +**권장 조치**: `fit_weights.py` 기본값을 `0.23`으로 변경하거나, +`validator.yaml`의 pass_threshold를 학습 스크립트와 동기화하는 가이드 추가. + +--- + +### ⚠️ 3. `extract_metrics.py` 미구현 — 학습 데이터 생성 파이프라인 공백 + +**현황**: WeightFitter는 이미지가 아닌 pre-extracted metrics JSONL을 입력으로 받는다. +이미지 → metrics 변환 스크립트(`extract_metrics.py`)가 없어 +현재 학습 데이터를 수동으로 만들어야 한다. + +**흐름 (이상)**: +``` +이미지 + RGBA 레이어 → [extract_metrics.py] → labeled.jsonl → [fit_weights.py] → weights.json +``` + +**흐름 (현재)**: +``` +bundle_store_dir에 저장된 VerificationBundle → (label 수작업) → [fit_weights.py] → weights.json +``` + +Bundle JSON에는 이미 `bundle.logical.signals`, `bundle.perception.signals` 등 metrics가 포함되어 있으므로, +Bundle → JSONL 변환 스크립트(`bundle_to_jsonl.py`) 작성으로 해소 가능. + +--- + +### ⚠️ 4. `DIR.md` 미갱신 + +**현황**: `DIR.md`의 디렉터리 구조 문서가 아래 추가 파일들을 반영하지 않음: + +| 추가된 파일 | 내용 | +|------------|------| +| `adapters/outbound/bundle_store.py` | LocalJsonBundleStore 구현체 | +| `application/ports/models.py` (추가) | BundleStorePort 프로토콜 | +| `engine/src/ML/` | 학습 파이프라인 전체 | +| `engine/src/ML/data/` | MVP 번들 저장 디렉터리 | +| `engine/src/sample/` | 샘플 테스트 이미지 + test_samples.py | + +--- + +### ℹ️ 5. 원본 plan_pipeline.md — 학습 파이프라인 미포함 (의도적) + +원본 계획은 추론 파이프라인만을 다루며 학습 파이프라인을 명시적으로 제외했다. +이는 오류가 아니라 설계 범위의 차이이며, `plan_weights.md` / `plan_weights_R.md`에서 보완 기술되었다. + +--- + +## 6. 테스트 현황 + +``` +82 passed, 6 skipped +``` + +| 테스트 파일 | 내용 | 상태 | +|------------|------|------| +| test_validator_e2e.py | Phase 4 수식 검증 + 전체 E2E (Dummy 어댑터) | ✅ | +| test_validator_scoring.py | resolve_answer, integrate_verification_v2, compute_difficulty | ✅ | +| test_validator_pipeline_smoke.py | 파이프라인 Smoke 테스트 | ✅ | +| test_architecture_constraints.py | use case 직접 파일 쓰기 금지, 200줄 제한 등 | ✅ | +| test_hexagonal_boundaries.py | 레이어 간 import 방향 제약 | ✅ | +| (skip) | 실제 MobileSAM/Moondream2/YOLO 로드 (GPU/모델 미설치) | skip | + +--- + +## 7. 샘플 이미지 테스트 결과 + +`engine/src/sample/test_samples.py`로 PNG 3장 테스트 수행. + +**알파채널 생성 방식**: PIL 색상 군집화(6클러스터) + 마스크 팽창(10px)으로 RGBA 레이어 생성, +레이어 간 overlap을 통해 occlusion과 z_depth_hop이 실값으로 계산됨. + +| 이미지 | occlusion 범위 | z_depth_hop | answer_obj_count | total_score | +|--------|--------------|-------------|-----------------|-------------| +| sample_1.png (640×415) | 0.0 ~ 0.999 | 0 ~ 1 | 5 / 6 | 0.483 ✅ | +| sample_2.png (640×415) | 0.0 ~ 1.000 | 0 ~ 1 | 5 / 6 | 0.483 ✅ | +| sample_3.png (793×521) | 0.0 ~ 0.994 | 0 ~ 1 | 5 / 6 | 0.483 ✅ | + +**관찰 사항**: + +- `occlusion_ratio`가 실측됨 (0.77~0.999): 마스크 팽창(10px)으로 색상 군집들이 넓게 퍼져 위 레이어가 아래 레이어를 대부분 덮음 +- `z_depth_hop = 1`이 대부분: 모든 레이어가 최상위 레이어와 직접 overlap → BFS hop = 1 +- `total_score = 0.4831`이 3개 이미지 동일: **PHASE 2/3가 더미값이므로** 이미지 내용 무관하게 같은 점수 +- 실제 이미지별 차이를 보려면 GPU에서 Moondream2(PHASE 2) + YOLO+CLIP(PHASE 3) 실행 필요 + +**제한**: 실제 씬 제작 시에는 각 객체를 **RGBA PNG 레이어로 분리 저장**하는 것이 전제. +단일 composite PNG로는 occlusion 실측이 시뮬레이션이며 실제 가림 관계를 반영하지 못함. + +--- + +## 8. 잔여 과제 + +| 과제 | 우선순위 | 내용 | +|------|---------|------| +| `bundle_to_jsonl.py` 작성 | 높음 | Bundle JSON → labeled JSONL 변환 (학습 데이터 생성 파이프라인 완성) | +| `is_hidden_min_conditions` 연결 | 중간 | YAML 값을 `resolve_answer()`에 주입 | +| `pass_threshold` 동기화 | 중간 | 운영(0.23) ↔ 학습 기본값(0.35) 정렬 | +| `DIR.md` 갱신 | 낮음 | ML/, bundle_store.py, BundleStorePort 등 반영 | +| label 수집 프로세스 정의 | 낮음 | VerificationBundle.label을 어떻게 채울지 (사람 판단 / 게임 플레이 로그 / 룰 기반) | +| GPU 환경 실측 테스트 | 별도 | MobileSAM + Moondream2 + YOLO+CLIP 실제 실행 | diff --git a/pyproject.toml b/pyproject.toml index 612c109..ec64dab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ requires-python = ">=3.11" dependencies = [ "hydra-core>=1.3.2", "pydantic>=2.12.5", - "typer>=0.24.1", + "typer>=0.24.1" ] [project.optional-dependencies] @@ -34,6 +34,14 @@ ml-gpu = [ "torch>=2.10.0", "transformers>=5.2.0", ] +validator = [ + "bitsandbytes>=0.49.2", + "mobile-sam", + "networkx>=3.6.1", + "numpy>=2.4.2", + "pillow>=12.1.1", + "ultralytics>=8.4.19", +] dev = [ "mypy>=1.19.1", "pytest>=9.0.2", @@ -55,6 +63,9 @@ discoverex = "discoverex.adapters.inbound.cli.main:app" [tool.uv] index-url = "https://pypi.org/simple" +[tool.uv.sources] +mobile-sam = { git = "https://github.com/ChaoningZhang/MobileSAM.git" } + [tool.pytest.ini_options] minversion = "8.0" addopts = "-q" diff --git a/src/ML/__init__.py b/src/ML/__init__.py new file mode 100644 index 0000000..02ba51a --- /dev/null +++ b/src/ML/__init__.py @@ -0,0 +1 @@ +# ML 학습 패키지 — engine/ 추론 코드와 별도로 동작하는 학습 전용 모듈 diff --git a/src/ML/data/.gitkeep b/src/ML/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/ML/fit_weights.py b/src/ML/fit_weights.py new file mode 100644 index 0000000..0e89cd4 --- /dev/null +++ b/src/ML/fit_weights.py @@ -0,0 +1,103 @@ +"""가중치 학습 스크립트. + +사용법 +------ + python engine/src/ML/fit_weights.py \\ + --labeled-jsonl data/labeled.jsonl \\ + --weights-out engine/src/ML/weights.json \\ + [--weights-in engine/src/ML/weights.json] # 이전 학습값에서 이어 학습 + [--pass-threshold 0.35] + [--margin 0.05] + +레이블 데이터 형식 (JSONL, 한 줄 = 한 오브젝트): + {"metrics": {"sigma_threshold": 2.0, "detail_retention_rate": 0.3, + "hop": 2, "diameter": 4.0, "degree_norm": 0.7}, "label": true} + {"metrics": {"sigma_threshold": 8.0, "detail_retention_rate": 0.9, + "hop": 0, "diameter": 4.0, "degree_norm": 0.1}, "label": false} + +출력 +---- + weights.json — 학습된 ScoringWeights (engine/conf/validator.yaml 의 + weights_path 에 지정하면 추론 시 자동 로드) + stdout — {"weights_path": "...", "final_loss": 0.012, "n_samples": N} +""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +# engine 패키지 경로 추가 (editable install 없이도 동작) +# 이 파일 위치: engine/src/ML/fit_weights.py +# engine/src/ 는 parent.parent +_ENGINE_SRC = Path(__file__).resolve().parent.parent +if str(_ENGINE_SRC) not in sys.path: + sys.path.insert(0, str(_ENGINE_SRC)) + +from discoverex.domain.services.verification import ScoringWeights # noqa: E402 +from ML.weight_fitter import WeightFitter # noqa: E402 + + +def _parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description="ScoringWeights 학습 스크립트") + p.add_argument("--labeled-jsonl", required=True, type=Path, + help="레이블 데이터 JSONL 파일 경로") + p.add_argument("--weights-out", required=True, type=Path, + help="학습된 가중치를 저장할 JSON 경로") + p.add_argument("--weights-in", type=Path, default=None, + help="초기 가중치 JSON (없으면 ScoringWeights 기본값 사용)") + p.add_argument("--pass-threshold", type=float, default=0.35) + p.add_argument("--margin", type=float, default=0.05, + help="hinge loss 마진 (기본 0.05)") + p.add_argument("--max-iter", type=int, default=5000) + return p.parse_args() + + +def main() -> None: + args = _parse_args() + + # 레이블 데이터 로드 + lines = args.labeled_jsonl.read_text(encoding="utf-8").strip().splitlines() + labeled: list[tuple[dict, bool]] = [] + for line in lines: + if not line.strip(): + continue + obj = json.loads(line) + labeled.append((obj["metrics"], bool(obj["label"]))) + + if not labeled: + print(json.dumps({"error": "레이블 데이터가 비어 있습니다."}), flush=True) + sys.exit(1) + + # 초기 가중치 로드 + init_weights: ScoringWeights | None = None + if args.weights_in is not None: + init_weights = ScoringWeights.model_validate_json( + args.weights_in.read_text(encoding="utf-8") + ) + + # 학습 + fitter = WeightFitter() + optimised = fitter.fit( + labeled=labeled, + init_weights=init_weights, + margin=args.margin, + pass_threshold=args.pass_threshold, + max_iter=args.max_iter, + ) + + # 저장 + args.weights_out.parent.mkdir(parents=True, exist_ok=True) + args.weights_out.write_text(optimised.model_dump_json(indent=2), encoding="utf-8") + + # 결과 출력 + result = { + "weights_path": str(args.weights_out), + "n_samples": len(labeled), + } + print(json.dumps(result, ensure_ascii=False), flush=True) + + +if __name__ == "__main__": + main() diff --git a/src/ML/weight_fitter.py b/src/ML/weight_fitter.py new file mode 100644 index 0000000..7adbe8e --- /dev/null +++ b/src/ML/weight_fitter.py @@ -0,0 +1,185 @@ +"""WeightFitter — ScoringWeights 학습 루프. + +이 모듈은 engine/ 의 추론 코드와 독립적으로 동작하는 학습 전용 코드이다. +학습 결과(weights.json)만 engine/conf/validator.yaml 의 weights_path 에 지정해 +추론 시 사용하며, 이 파일 자체는 추론 환경에 포함되지 않아도 된다. + +파라미터 영역 구분 +------------------ +┌─────────────────────────────────────────────────────────────────┐ +│ 학습 가능 (WeightFitter 최적화 대상) │ +│ PHASE 4 ScoringWeights — 연속 미분 가능, Nelder-Mead 최적화 │ +│ perception_sigma, perception_drr │ +│ logical_hop, logical_degree │ +│ total_perception (total_logical = 1 - total_perception) │ +├─────────────────────────────────────────────────────────────────┤ +│ 동결 (frozen) — step function → 미분 불가, WeightFitter 제외 │ +│ PHASE 1 cluster_radius_factor (conf/models/.../mobilesam.yaml) │ +│ cluster_radius = mean_dist × factor │ +│ PHASE 3 iou_match_threshold (conf/models/.../yolo_clip.yaml) │ +│ IoU < threshold → 객체 사라짐으로 판정 │ +│ PHASE 3 sigma_levels (conf/models/.../yolo_clip.yaml) │ +│ 이산 블러 격자 — feature space 자체를 정의 │ +│ │ +│ → YAML에서 수동으로 값을 바꾸면 파이프라인 전체에 반영됨 │ +├─────────────────────────────────────────────────────────────────┤ +│ 미학습 고정 (difficulty_* 6개) │ +│ 난이도 진단용 가중치 — 학습 필요 시 init_weights 로 초기화 후 │ +│ WeightFitter 확장 가능 (현재는 init_weights 값 그대로 유지) │ +└─────────────────────────────────────────────────────────────────┘ + +학습 대상 +---------- +scoring 관련 5개 파라미터: + perception_sigma, perception_drr, logical_hop, logical_degree, total_perception +total_logical 은 1.0 - total_perception 으로 자동 파생 (합 = 1.0 보장). +difficulty_* 6개는 난이도 진단용이므로 기본값 유지. + +손실 함수 +---------- +hinge loss: + label=True → loss = max(0, threshold + margin - score) + label=False → loss = max(0, score - (threshold - margin)) + +최적화 알고리즘 +--------------- +scipy.optimize.minimize — Nelder-Mead (gradient-free, GPU 불필요) + +사용 예 +------- + python engine/src/ML/weight_fitter.py + 또는 engine/src/ML/fit_weights.py 스크립트 참조 +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np +from scipy.optimize import minimize + +# engine 패키지 경로를 sys.path 에 추가 (editable install 없이도 동작) +# 이 파일 위치: engine/src/ML/weight_fitter.py +# engine/src/ 는 parent.parent +_ENGINE_SRC = Path(__file__).resolve().parent.parent +if str(_ENGINE_SRC) not in sys.path: + sys.path.insert(0, str(_ENGINE_SRC)) + +from discoverex.domain.services.verification import ScoringWeights, integrate_verification_v2 # noqa: E402 + +# 최적화할 가중치 키 순서 (x 벡터 인덱스와 1:1 대응) +_SCORED_KEYS: list[str] = [ + "perception_sigma", + "perception_drr", + "logical_hop", + "logical_degree", + "total_perception", # total_logical = 1.0 - total_perception 으로 파생 +] + + +class WeightFitter: + """레이블 데이터를 이용해 ScoringWeights 의 scoring 관련 가중치를 최적화한다. + + 사용 예 + ------- + >>> fitter = WeightFitter() + >>> labeled = [ + ... ({"sigma_threshold": 2.0, "detail_retention_rate": 0.3, + ... "hop": 2, "diameter": 4.0, "degree_norm": 0.7}, True), + ... ({"sigma_threshold": 8.0, "detail_retention_rate": 0.9, + ... "hop": 0, "diameter": 4.0, "degree_norm": 0.1}, False), + ... ] + >>> optimised = fitter.fit(labeled, pass_threshold=0.35) + >>> print(optimised.model_dump_json(indent=2)) + """ + + def fit( + self, + labeled: list[tuple[dict, bool]], + init_weights: ScoringWeights | None = None, + margin: float = 0.05, + pass_threshold: float = 0.35, + max_iter: int = 5000, + ) -> ScoringWeights: + """레이블 데이터로 ScoringWeights 를 최적화해서 반환한다. + + Parameters + ---------- + labeled: + (obj_metrics dict, 정답_pass bool) 쌍의 리스트. + obj_metrics 키는 integrate_verification_v2 와 동일. + init_weights: + 초기 가중치. None 이면 ScoringWeights() 기본값 사용. + margin: + hinge loss 의 안전 마진. 기본 0.05. + label=True 이면 threshold+margin 이상 달성이 목표. + label=False 이면 threshold-margin 미만 달성이 목표. + pass_threshold: + 판정 기준선. 기본 0.35. + max_iter: + Nelder-Mead 최대 반복 횟수. 기본 5000. + + Returns + ------- + 최적화된 ScoringWeights. + difficulty_* 는 init_weights 값을 그대로 유지. + """ + if not labeled: + return init_weights or ScoringWeights() + + w0 = init_weights or ScoringWeights() + x0 = np.array([getattr(w0, k) for k in _SCORED_KEYS], dtype=float) + + def _loss(x: np.ndarray) -> float: + x_pos = np.clip(x, 1e-6, None) + total_p = float(x_pos[4]) + total_l = max(1.0 - total_p, 1e-6) + w = ScoringWeights( + perception_sigma=float(x_pos[0]), + perception_drr=float(x_pos[1]), + logical_hop=float(x_pos[2]), + logical_degree=float(x_pos[3]), + total_perception=total_p, + total_logical=total_l, + # difficulty_* 는 초기값 유지 + difficulty_occlusion=w0.difficulty_occlusion, + difficulty_sigma=w0.difficulty_sigma, + difficulty_hop=w0.difficulty_hop, + difficulty_degree=w0.difficulty_degree, + difficulty_drr=w0.difficulty_drr, + difficulty_interaction=w0.difficulty_interaction, + ) + loss = 0.0 + for metrics, label in labeled: + _, _, score = integrate_verification_v2(metrics, weights=w) + if label: + loss += max(0.0, pass_threshold + margin - score) + else: + loss += max(0.0, score - (pass_threshold - margin)) + return loss / len(labeled) + + result = minimize( + _loss, + x0, + method="Nelder-Mead", + options={"maxiter": max_iter, "xatol": 1e-5, "fatol": 1e-5}, + ) + + x_opt = np.clip(result.x, 1e-6, None) + total_p_opt = float(x_opt[4]) + total_l_opt = max(1.0 - total_p_opt, 1e-6) + + return ScoringWeights( + perception_sigma=float(x_opt[0]), + perception_drr=float(x_opt[1]), + logical_hop=float(x_opt[2]), + logical_degree=float(x_opt[3]), + total_perception=total_p_opt, + total_logical=total_l_opt, + difficulty_occlusion=w0.difficulty_occlusion, + difficulty_sigma=w0.difficulty_sigma, + difficulty_hop=w0.difficulty_hop, + difficulty_degree=w0.difficulty_degree, + difficulty_drr=w0.difficulty_drr, + difficulty_interaction=w0.difficulty_interaction, + ) diff --git a/src/discoverex/adapters/inbound/cli/main.py b/src/discoverex/adapters/inbound/cli/main.py index 19c9590..baa88ba 100644 --- a/src/discoverex/adapters/inbound/cli/main.py +++ b/src/discoverex/adapters/inbound/cli/main.py @@ -10,9 +10,9 @@ run_replay_eval, run_verify_only, ) -from discoverex.bootstrap import build_context +from discoverex.bootstrap import build_context, build_validator_context from discoverex.config import PipelineConfig -from discoverex.config_loader import load_pipeline_config +from discoverex.config_loader import load_pipeline_config, load_validator_config from discoverex.domain.scene import Scene app = typer.Typer(no_args_is_help=True) @@ -99,5 +99,41 @@ def replay_eval_command( typer.echo(json.dumps({"report": str(report_path)}, ensure_ascii=False)) +@app.command("validate") +def validate_command( + composite_image: Path = typer.Argument(..., help="Path to the composite scene image"), + object_layer: list[Path] = typer.Option( + ..., "--object-layer", help="Object layer PNG (repeat per object)" + ), + config_name: str = typer.Option("validator", "--config-name"), + config_dir: str = typer.Option("conf", "--config-dir"), + override: list[str] = typer.Option([], "--override", "-o"), +) -> None: + """Run the 4-phase Validator pipeline on a composite image. + + Output JSON: {"status", "pass", "total_score", "perception_score", + "logical_score", "answer_obj_count", "failure_reason"} + """ + config = load_validator_config( + config_name=config_name, + config_dir=config_dir, + overrides=override, + ) + orchestrator = build_validator_context(config=config) + bundle = orchestrator.run( + composite_image=composite_image, object_layers=object_layer + ) + payload = { + "status": "pass" if bundle.final.pass_ else "fail", + "pass": bundle.final.pass_, + "total_score": round(bundle.final.total_score, 4), + "perception_score": round(bundle.perception.score, 4), + "logical_score": round(bundle.logical.score, 4), + "answer_obj_count": bundle.logical.signals.get("answer_obj_count", 0), + "failure_reason": bundle.final.failure_reason, + } + typer.echo(json.dumps(payload, ensure_ascii=False)) + + if __name__ == "__main__": app() diff --git a/src/discoverex/adapters/outbound/bundle_store.py b/src/discoverex/adapters/outbound/bundle_store.py new file mode 100644 index 0000000..0ccfc20 --- /dev/null +++ b/src/discoverex/adapters/outbound/bundle_store.py @@ -0,0 +1,56 @@ +"""LocalJsonBundleStore — VerificationBundle MVP 데이터 수집 어댑터. + +MVP 운영 중 생성된 모든 VerificationBundle 을 JSON 파일로 저장한다. +label 필드는 null 로 초기화되며, 나중에 사용자 피드백(true/false)으로 채워 +WeightFitter 학습 데이터로 활용할 수 있다. + +저장 형식 +--------- +{store_dir}/{YYYYMMDD_HHMMSS}_{uuid8}.json + + { + "scene_id": "20240305_143022_a3f8b2c1", + "timestamp": "2024-03-05T14:30:22.123456+00:00", + "composite_image": "/abs/path/to/composite.png", + "object_layers": ["/abs/path/to/obj_0.png", ...], + "label": null, + "bundle": { "perception": {...}, "logical": {...}, "final": {...} } + } +""" +from __future__ import annotations + +import json +import uuid +from datetime import datetime, timezone +from pathlib import Path + +from discoverex.domain.verification import VerificationBundle + + +class LocalJsonBundleStore: + """BundleStorePort 구현체 — 로컬 파일시스템에 JSON 으로 저장.""" + + def __init__(self, store_dir: Path) -> None: + self._store_dir = store_dir + self._store_dir.mkdir(parents=True, exist_ok=True) + + def save( + self, + bundle: VerificationBundle, + composite_image: Path, + object_layers: list[Path], + ) -> None: + ts = datetime.now(tz=timezone.utc) + scene_id = f"{ts.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}" + + record = { + "scene_id": scene_id, + "timestamp": ts.isoformat(), + "composite_image": str(composite_image), + "object_layers": [str(p) for p in object_layers], + "label": None, + "bundle": bundle.model_dump(by_alias=True), + } + (self._store_dir / f"{scene_id}.json").write_text( + json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8" + ) diff --git a/src/discoverex/adapters/outbound/models/dummy.py b/src/discoverex/adapters/outbound/models/dummy.py index 54cd77b..626ff8c 100644 --- a/src/discoverex/adapters/outbound/models/dummy.py +++ b/src/discoverex/adapters/outbound/models/dummy.py @@ -1,13 +1,18 @@ from __future__ import annotations +from pathlib import Path + from discoverex.models.types import ( FxPrediction, FxRequest, HiddenRegionRequest, InpaintPrediction, InpaintRequest, + LogicalStructure, ModelHandle, PerceptionRequest, + PhysicalMetadata, + VisualVerification, ) from .fx_artifact import ensure_output_image @@ -112,3 +117,71 @@ def predict( ensure_output_image(output_path) prediction["output_path"] = output_path return prediction + + +# --------------------------------------------------------------------------- +# Validator pipeline dummy adapters (load/extract|verify/unload pattern) +# --------------------------------------------------------------------------- + +class DummyPhysicalExtraction: + """Dummy Phase 1: returns fixed physical metadata for two objects.""" + + def load(self, handle: ModelHandle) -> None: # noqa: ARG002 + pass + + def extract( + self, composite_image: Path, object_layers: list[Path] # noqa: ARG002 + ) -> PhysicalMetadata: + obj_ids = [f"obj_{i}" for i in range(len(object_layers))] or ["obj_0", "obj_1"] + return PhysicalMetadata( + regions=[{"obj_id": oid, "bbox": [0.1, 0.1, 0.2, 0.2]} for oid in obj_ids], + occlusion_map={oid: 0.45 for oid in obj_ids}, + z_index_map={oid: i for i, oid in enumerate(obj_ids)}, + z_depth_hop_map={oid: 2 for oid in obj_ids}, + cluster_density_map={oid: 3 for oid in obj_ids}, + euclidean_distance_map={oid: [50.0, 80.0] for oid in obj_ids}, + ) + + def unload(self) -> None: + pass + + +class DummyLogicalExtraction: + """Dummy Phase 2: returns fixed scene graph metrics.""" + + def load(self, handle: ModelHandle) -> None: # noqa: ARG002 + pass + + def extract( + self, composite_image: Path, physical: PhysicalMetadata # noqa: ARG002 + ) -> LogicalStructure: + obj_ids = list(physical.occlusion_map.keys()) or ["obj_0", "obj_1"] + return LogicalStructure( + relations=[{"subject": obj_ids[0], "predicate": "near", "object": obj_ids[-1]}], + degree_map={oid: 3 for oid in obj_ids}, + hop_map={oid: 2 for oid in obj_ids}, + diameter=4.0, + ) + + def unload(self) -> None: + pass + + +class DummyVisualVerification: + """Dummy Phase 3: returns fixed sigma threshold and DRR values.""" + + def load(self, handle: ModelHandle) -> None: # noqa: ARG002 + pass + + def verify( + self, composite_image: Path, sigma_levels: list[float] # noqa: ARG002 + ) -> VisualVerification: + # Use deterministic obj IDs — orchestrator unifies by intersection of maps + obj_ids = ["obj_0", "obj_1"] + return VisualVerification( + sigma_threshold_map={oid: 4.0 for oid in obj_ids}, + detail_retention_rate_map={oid: 0.55 for oid in obj_ids}, + ) + + def unload(self) -> None: + pass diff --git a/src/discoverex/adapters/outbound/models/hf_mobilesam.py b/src/discoverex/adapters/outbound/models/hf_mobilesam.py new file mode 100644 index 0000000..4c365ee --- /dev/null +++ b/src/discoverex/adapters/outbound/models/hf_mobilesam.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +import math +from pathlib import Path +from typing import Any + +from discoverex.models.types import ModelHandle, PhysicalMetadata + + +class MobileSAMAdapter: + """ + Phase 1: Physical metadata extraction using MobileSAM. + + VRAM lifecycle: load() → extract() → unload() + Pre-processing (pixel comparison for z_index / occlusion) is pure CPU + and runs before the SAM model is invoked. + """ + + def __init__( + self, + model_id: str = "ChaoningZhang/MobileSAM", + device: str = "cuda", + dtype: str = "float16", + cluster_radius_factor: float = 0.5, # frozen hyperparam — step fn, not differentiable + ) -> None: + self._model_id = model_id + self._device = device + self._dtype = dtype + self._cluster_radius_factor = cluster_radius_factor + self._model: Any = None + self._predictor: Any = None + + # ------------------------------------------------------------------ + + def load(self, handle: ModelHandle) -> None: # noqa: ARG002 + import torch + from mobile_sam import SamPredictor, sam_model_registry + + dtype = torch.float16 if self._dtype == "float16" else torch.float32 + sam = sam_model_registry["vit_t"](checkpoint=None) + sam = sam.to(device=self._device, dtype=dtype) + sam.eval() + self._model = sam + self._predictor = SamPredictor(sam) + + def extract( + self, composite_image: Path, object_layers: list[Path] + ) -> PhysicalMetadata: + import numpy as np + from PIL import Image + + composite = np.array(Image.open(composite_image).convert("RGBA")) + + # ------------------------------------------------------------------ + # Pre-processing (pure CPU): occlusion, z_index, z_depth_hop + # ------------------------------------------------------------------ + layer_arrays = [ + np.array(Image.open(p).convert("RGBA")) for p in object_layers + ] + + occlusion_map: dict[str, float] = {} + z_index_map: dict[str, int] = {} + z_depth_hop_map: dict[str, int] = {} + + for i, (layer, layer_path) in enumerate(zip(layer_arrays, object_layers)): + obj_id = layer_path.stem + alpha = layer[:, :, 3] > 0 + total_pixels = int(alpha.sum()) + if total_pixels == 0: + occlusion_map[obj_id] = 0.0 + z_index_map[obj_id] = i + z_depth_hop_map[obj_id] = 0 + continue + + # Pixels where layer is non-transparent but composite differs → occluded + comp_alpha = composite[:, :, 3] > 0 + visible = alpha & comp_alpha + visible_count = int(visible.sum()) + occlusion_map[obj_id] = max(0.0, 1.0 - visible_count / total_pixels) + z_index_map[obj_id] = i + + # Compute z_depth_hop via pixel-overlap Z-occlusion graph (BFS shortest path) + # Edge j→i: layer j (higher Z) overlaps layer i (lower Z) in alpha pixels + import networkx as nx + + n = len(object_layers) + g: nx.DiGraph = nx.DiGraph() + for layer_path in object_layers: + g.add_node(layer_path.stem) + + for i in range(n): + alpha_i = layer_arrays[i][:, :, 3] > 0 + for j in range(i + 1, n): # j is above i in Z-order + alpha_j = layer_arrays[j][:, :, 3] > 0 + if (alpha_i & alpha_j).any(): + g.add_edge(object_layers[j].stem, object_layers[i].stem) + + roots = [nd for nd in g.nodes if g.in_degree(nd) == 0] or list(g.nodes)[:1] + for layer_path in object_layers: + obj_id = layer_path.stem + hops = [] + for root in roots: + try: + hops.append(nx.shortest_path_length(g, root, obj_id)) + except nx.NetworkXNoPath: + pass + z_depth_hop_map[obj_id] = max(hops, default=0) + + # ------------------------------------------------------------------ + # SAM segmentation: generate precise masks and bounding boxes + # ------------------------------------------------------------------ + import numpy as np + + comp_rgb = composite[:, :, :3] + self._predictor.set_image(comp_rgb) + + regions: list[dict] = [] + euclidean_distance_map: dict[str, list[float]] = {} + cluster_density_map: dict[str, int] = {} + centers: dict[str, tuple[float, float]] = {} + + for layer, layer_path in zip(layer_arrays, object_layers): + obj_id = layer_path.stem + alpha = layer[:, :, 3] > 0 + if not alpha.any(): + continue + + ys, xs = np.where(alpha) + cx, cy = float(xs.mean()), float(ys.mean()) + centers[obj_id] = (cx, cy) + + x1, y1, x2, y2 = int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max()) + h, w = composite.shape[:2] + regions.append({ + "obj_id": obj_id, + "bbox": [x1 / w, y1 / h, (x2 - x1) / w, (y2 - y1) / h], + "center": [cx / w, cy / h], + "area": float(alpha.sum()) / (w * h), + }) + + # Euclidean distances between object centers + obj_ids = list(centers.keys()) + for obj_id, (cx, cy) in centers.items(): + dists = [ + math.hypot(cx - centers[oid][0], cy - centers[oid][1]) + for oid in obj_ids if oid != obj_id + ] + euclidean_distance_map[obj_id] = dists + mean_dist = sum(dists) / len(dists) if dists else float("inf") + cluster_radius = mean_dist * self._cluster_radius_factor + cluster_density_map[obj_id] = sum( + 1 for d in dists if d <= cluster_radius + ) + + return PhysicalMetadata( + regions=regions, + occlusion_map=occlusion_map, + z_index_map=z_index_map, + z_depth_hop_map=z_depth_hop_map, + cluster_density_map=cluster_density_map, + euclidean_distance_map=euclidean_distance_map, + ) + + def unload(self) -> None: + import gc + + import torch + + del self._model + del self._predictor + self._model = None + self._predictor = None + gc.collect() + if torch.cuda.is_available(): + torch.cuda.empty_cache() diff --git a/src/discoverex/adapters/outbound/models/hf_moondream2.py b/src/discoverex/adapters/outbound/models/hf_moondream2.py new file mode 100644 index 0000000..7caecb9 --- /dev/null +++ b/src/discoverex/adapters/outbound/models/hf_moondream2.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any + +from discoverex.models.types import LogicalStructure, ModelHandle, PhysicalMetadata + + +class Moondream2Adapter: + """ + Phase 2: Logical scene graph extraction using Moondream2 (4-bit quantized VLM). + + VRAM lifecycle: load() → extract() → unload() + Context-Aware Prompting: Phase 1 coordinates are injected into the prompt + so the model anchors its spatial reasoning to known object positions. + Graph metrics (degree, hop, diameter) are computed via NetworkX on CPU. + """ + + def __init__( + self, + model_id: str = "vikhyat/moondream2", + quantization: str = "4bit", + device: str = "cuda", + dtype: str = "float16", + ) -> None: + self._model_id = model_id + self._quantization = quantization + self._device = device + self._dtype = dtype + self._model: Any = None + self._tokenizer: Any = None + + # ------------------------------------------------------------------ + + def load(self, handle: ModelHandle) -> None: # noqa: ARG002 + import torch + from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig + + quant_cfg = None + if self._quantization == "4bit": + quant_cfg = BitsAndBytesConfig( + load_in_4bit=True, + bnb_4bit_compute_dtype=torch.float16, + ) + + self._tokenizer = AutoTokenizer.from_pretrained( + self._model_id, trust_remote_code=True + ) + self._model = AutoModelForCausalLM.from_pretrained( + self._model_id, + trust_remote_code=True, + quantization_config=quant_cfg, + device_map=self._device if quant_cfg is None else "auto", + ) + self._model.eval() + + def extract( + self, composite_image: Path, physical: PhysicalMetadata + ) -> LogicalStructure: + from PIL import Image + + image = Image.open(composite_image).convert("RGB") + prompt = self._build_prompt(physical) + response = self._query_model(image, prompt) + relations = self._parse_relations(response) + return self._build_graph(relations, physical) + + def unload(self) -> None: + import gc + + import torch + + del self._model + del self._tokenizer + self._model = None + self._tokenizer = None + gc.collect() + if torch.cuda.is_available(): + torch.cuda.empty_cache() + + # ------------------------------------------------------------------ + # Internals + # ------------------------------------------------------------------ + + def _build_prompt(self, physical: PhysicalMetadata) -> str: + obj_desc = "; ".join( + f"{r['obj_id']} at bbox {r['bbox']}" + for r in physical.regions + ) + return ( + f"The scene contains these objects: {obj_desc}. " + "For each pair of objects, describe their spatial and logical relationship. " + "Reply as a JSON array: " + '[{"subject": "obj_id", "predicate": "relation", "object": "obj_id"}, ...]' + ) + + def _query_model(self, image: Any, prompt: str) -> str: + # Moondream2 custom API: encode_image → answer_question + with __import__("torch").no_grad(): + enc_image = self._model.encode_image(image) + answer = self._model.answer_question(enc_image, prompt, self._tokenizer) + return answer if isinstance(answer, str) else str(answer) + + def _parse_relations(self, response: str) -> list[dict]: + # Extract JSON array from free-form model response + match = re.search(r"\[.*\]", response, re.DOTALL) + if not match: + return [] + try: + data = json.loads(match.group()) + return [r for r in data if isinstance(r, dict)] + except json.JSONDecodeError: + return [] + + def _build_graph( + self, relations: list[dict], physical: PhysicalMetadata + ) -> LogicalStructure: + import networkx as nx + + g: nx.DiGraph = nx.DiGraph() + obj_ids = [r["obj_id"] for r in physical.regions] + g.add_nodes_from(obj_ids) + + for rel in relations: + subj = rel.get("subject", "") + obj = rel.get("object", "") + if subj and obj and subj in obj_ids and obj in obj_ids: + g.add_edge(subj, obj, predicate=rel.get("predicate", "")) + + degree_map = {n: g.degree(n) for n in g.nodes} + + # Hop from root = longest shortest path from any source node (degree == 0) + root_candidates = [n for n in g.nodes if g.in_degree(n) == 0] or list(g.nodes)[:1] + hop_map: dict[str, int] = {} + for node in g.nodes: + hops = [] + for root in root_candidates: + try: + hops.append(nx.shortest_path_length(g, root, node)) + except nx.NetworkXNoPath: + pass + hop_map[node] = max(hops, default=0) + + ug = g.to_undirected() + try: + diameter = float(nx.diameter(ug)) if len(ug) > 0 and nx.is_connected(ug) else float(len(ug)) + except nx.NetworkXError: + diameter = float(max(len(c) for c in nx.connected_components(ug)) if ug else 1) + + return LogicalStructure( + relations=relations, + degree_map=degree_map, + hop_map=hop_map, + diameter=max(diameter, 1.0), + ) diff --git a/src/discoverex/adapters/outbound/models/hf_yolo_clip.py b/src/discoverex/adapters/outbound/models/hf_yolo_clip.py new file mode 100644 index 0000000..c259e6d --- /dev/null +++ b/src/discoverex/adapters/outbound/models/hf_yolo_clip.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from discoverex.models.types import ModelHandle, VisualVerification + + +class YoloCLIPAdapter: + """ + Phase 3: Visual difficulty verification via YOLO + CLIP (parallel load). + + VRAM strategy: both models loaded simultaneously, combined < 4 GB. + YOLO tracks per-object sigma_threshold (disappearance blur level). + CLIP computes Detail Retention Rate (DRR) via self-similarity cosine score. + """ + + def __init__( + self, + yolo_model_id: str = "THU-MIG/yolov10-n", + clip_model_id: str = "openai/clip-vit-base-patch32", + sigma_levels: list[float] | None = None, + device: str = "cuda", + max_vram_gb: float = 4.0, + iou_match_threshold: float = 0.3, # frozen hyperparam — step fn, not differentiable + ) -> None: + self._yolo_model_id = yolo_model_id + self._clip_model_id = clip_model_id + self._sigma_levels = sigma_levels or [1.0, 2.0, 4.0, 8.0, 16.0] + self._device = device + self._max_vram_gb = max_vram_gb + self._iou_match_threshold = iou_match_threshold + self._yolo: Any = None + self._clip_model: Any = None + self._clip_processor: Any = None + + # ------------------------------------------------------------------ + + def load(self, handle: ModelHandle) -> None: # noqa: ARG002 + from transformers import CLIPModel, CLIPProcessor + from ultralytics import YOLO + + self._yolo = YOLO(self._yolo_model_id) + self._yolo.to(self._device) + + self._clip_model = CLIPModel.from_pretrained(self._clip_model_id).to(self._device) + self._clip_processor = CLIPProcessor.from_pretrained(self._clip_model_id) + self._clip_model.eval() + + def verify( + self, composite_image: Path, sigma_levels: list[float] + ) -> VisualVerification: + import numpy as np + import torch + from PIL import Image, ImageFilter + + original = Image.open(composite_image).convert("RGB") + orig_array = np.array(original) + W, H = original.size + + # Detect objects in original to establish baseline bounding boxes + baseline_results = self._yolo(orig_array, verbose=False) + baseline_boxes = baseline_results[0].boxes + if baseline_boxes is None or len(baseline_boxes) == 0: + return VisualVerification( + sigma_threshold_map={}, + detail_retention_rate_map={}, + ) + + baseline_xyxy = baseline_boxes.xyxy.cpu().numpy() # (n, 4) in pixel coords + n_objects = len(baseline_xyxy) + obj_ids = [f"obj_{i}" for i in range(n_objects)] + + # Per-object sigma_threshold: lowest σ at which IoU match drops below 0.3 + max_sigma = max(sigma_levels) + sigma_threshold_map: dict[str, float] = {oid: max_sigma for oid in obj_ids} + + for sigma in sorted(sigma_levels): + blurred = original.filter(ImageFilter.GaussianBlur(radius=sigma)) + results = self._yolo(np.array(blurred), verbose=False) + detected = results[0].boxes + detected_xyxy = detected.xyxy.cpu().numpy() if detected is not None and len(detected) > 0 else np.empty((0, 4)) + + for i, oid in enumerate(obj_ids): + if sigma_threshold_map[oid] != max_sigma: + continue # Already marked as disappeared + base_box = baseline_xyxy[i] + matched = any( + _iou(base_box, det_box) >= self._iou_match_threshold + for det_box in detected_xyxy + ) + if not matched: + sigma_threshold_map[oid] = sigma + + # Per-object DRR: bbox-crop CLIP cosine similarity (original vs max-blur) + max_blurred = original.filter(ImageFilter.GaussianBlur(radius=max_sigma)) + detail_retention_rate_map: dict[str, float] = {} + + for i, oid in enumerate(obj_ids): + x1, y1, x2, y2 = (int(v) for v in baseline_xyxy[i]) + x1, y1 = max(0, x1), max(0, y1) + x2, y2 = min(W, x2), min(H, y2) + if x2 <= x1 or y2 <= y1: + detail_retention_rate_map[oid] = 1.0 + continue + crop_orig = original.crop((x1, y1, x2, y2)) + crop_blur = max_blurred.crop((x1, y1, x2, y2)) + feat_orig = self._clip_image_features(crop_orig) + feat_blur = self._clip_image_features(crop_blur) + with torch.no_grad(): + sim = torch.nn.functional.cosine_similarity(feat_orig, feat_blur, dim=-1).item() + detail_retention_rate_map[oid] = float(max(0.0, min(1.0, sim))) + + return VisualVerification( + sigma_threshold_map=sigma_threshold_map, + detail_retention_rate_map=detail_retention_rate_map, + ) + + def unload(self) -> None: + import gc + + import torch + + del self._yolo + del self._clip_model + del self._clip_processor + self._yolo = None + self._clip_model = None + self._clip_processor = None + gc.collect() + if torch.cuda.is_available(): + torch.cuda.empty_cache() + + # ------------------------------------------------------------------ + + def _clip_image_features(self, image: Any) -> Any: + import torch + + inputs = self._clip_processor(images=image, return_tensors="pt").to(self._device) + with torch.no_grad(): + features = self._clip_model.get_image_features(**inputs) + return features + + +# ------------------------------------------------------------------ + +def _iou(box_a: Any, box_b: Any) -> float: + """Intersection-over-Union for two [x1, y1, x2, y2] boxes.""" + ix1 = max(box_a[0], box_b[0]) + iy1 = max(box_a[1], box_b[1]) + ix2 = min(box_a[2], box_b[2]) + iy2 = min(box_a[3], box_b[3]) + if ix2 <= ix1 or iy2 <= iy1: + return 0.0 + inter = (ix2 - ix1) * (iy2 - iy1) + area_a = (box_a[2] - box_a[0]) * (box_a[3] - box_a[1]) + area_b = (box_b[2] - box_b[0]) * (box_b[3] - box_b[1]) + union = area_a + area_b - inter + return inter / union if union > 0 else 0.0 diff --git a/src/discoverex/application/ports/models.py b/src/discoverex/application/ports/models.py index 456ff16..f2a0f37 100644 --- a/src/discoverex/application/ports/models.py +++ b/src/discoverex/application/ports/models.py @@ -2,14 +2,20 @@ from typing import Protocol +from pathlib import Path + +from discoverex.domain.verification import VerificationBundle from discoverex.models.types import ( FxPrediction, FxRequest, HiddenRegionRequest, InpaintPrediction, InpaintRequest, + LogicalStructure, ModelHandle, PerceptionRequest, + PhysicalMetadata, + VisualVerification, ) @@ -41,3 +47,55 @@ class FxPort(Protocol): def load(self, model_ref_or_version: str) -> ModelHandle: ... def predict(self, handle: ModelHandle, request: FxRequest) -> FxPrediction: ... + + +# --------------------------------------------------------------------------- +# Validator pipeline ports — load/extract(verify)/unload pattern +# Each port is responsible for its own VRAM lifecycle. +# --------------------------------------------------------------------------- + +class PhysicalExtractionPort(Protocol): + """Phase 1: MobileSAM-based physical metadata extraction.""" + + def load(self, handle: ModelHandle) -> None: ... + + def extract( + self, composite_image: Path, object_layers: list[Path] + ) -> PhysicalMetadata: ... + + def unload(self) -> None: ... + + +class LogicalExtractionPort(Protocol): + """Phase 2: Moondream2-based scene graph + logical relation extraction.""" + + def load(self, handle: ModelHandle) -> None: ... + + def extract( + self, composite_image: Path, physical: PhysicalMetadata + ) -> LogicalStructure: ... + + def unload(self) -> None: ... + + +class VisualVerificationPort(Protocol): + """Phase 3: YOLO+CLIP parallel visual difficulty verification.""" + + def load(self, handle: ModelHandle) -> None: ... + + def verify( + self, composite_image: Path, sigma_levels: list[float] + ) -> VisualVerification: ... + + def unload(self) -> None: ... + + +class BundleStorePort(Protocol): + """MVP 데이터 수집: VerificationBundle 을 영속화하는 아웃바운드 포트.""" + + def save( + self, + bundle: VerificationBundle, + composite_image: Path, + object_layers: list[Path], + ) -> None: ... diff --git a/src/discoverex/application/use_cases/validator/__init__.py b/src/discoverex/application/use_cases/validator/__init__.py new file mode 100644 index 0000000..82a7419 --- /dev/null +++ b/src/discoverex/application/use_cases/validator/__init__.py @@ -0,0 +1,3 @@ +from .orchestrator import ValidatorOrchestrator, run_validator + +__all__ = ["ValidatorOrchestrator", "run_validator"] diff --git a/src/discoverex/application/use_cases/validator/orchestrator.py b/src/discoverex/application/use_cases/validator/orchestrator.py new file mode 100644 index 0000000..aae0f46 --- /dev/null +++ b/src/discoverex/application/use_cases/validator/orchestrator.py @@ -0,0 +1,200 @@ +from __future__ import annotations + +from pathlib import Path + +from discoverex.application.ports.models import ( + BundleStorePort, + LogicalExtractionPort, + PhysicalExtractionPort, + VisualVerificationPort, +) +from discoverex.domain.services.verification import ( + ScoringWeights, + compute_scene_difficulty, + integrate_verification_v2, + resolve_answer, +) +from discoverex.domain.verification import ( + FinalVerification, + VerificationBundle, + VerificationResult, +) +from discoverex.models.types import ( + LogicalStructure, + ModelHandle, + PhysicalMetadata, + ValidatorInput, + VisualVerification, +) + +_DEFAULT_SIGMA_LEVELS: list[float] = [1.0, 2.0, 4.0, 8.0, 16.0] + + +class ValidatorOrchestrator: + """ + 4-Phase 순차 실행 Validator — VRAM 바통 패스 전략. + + 각 Phase 가 독립적으로 모델을 로드·실행·언로드하므로 + 피크 VRAM 이 8 GB 예산 내에 유지된다. + Phase 간 데이터는 JSON 직렬화 가능한 Pydantic 모델로 전달된다. + """ + + def __init__( + self, + physical_port: PhysicalExtractionPort, + logical_port: LogicalExtractionPort, + visual_port: VisualVerificationPort, + physical_handle: ModelHandle, + logical_handle: ModelHandle, + visual_handle: ModelHandle, + pass_threshold: float = 0.35, + scoring_weights: ScoringWeights | None = None, + sigma_levels: list[float] | None = None, + bundle_store: BundleStorePort | None = None, + ) -> None: + self._physical_port = physical_port + self._logical_port = logical_port + self._visual_port = visual_port + self._physical_handle = physical_handle + self._logical_handle = logical_handle + self._visual_handle = visual_handle + self._pass_threshold = pass_threshold + self._weights = scoring_weights or ScoringWeights() + self._sigma_levels = sigma_levels or _DEFAULT_SIGMA_LEVELS + self._bundle_store = bundle_store + + # ------------------------------------------------------------------ + # Public entry point + # ------------------------------------------------------------------ + + def run( + self, + composite_image: Path, + object_layers: list[Path], + ) -> VerificationBundle: + """4개 Phase 를 모두 실행하고 최종 VerificationBundle 을 반환한다.""" + physical = self._run_phase1(composite_image, object_layers) + logical = self._run_phase2(composite_image, physical) + visual = self._run_phase3(composite_image) + bundle = self._run_phase4(ValidatorInput(physical=physical, logical=logical, visual=visual)) + if self._bundle_store is not None: + self._bundle_store.save(bundle, composite_image, object_layers) + return bundle + + # ------------------------------------------------------------------ + # Phase runners + # ------------------------------------------------------------------ + + def _run_phase1( + self, composite_image: Path, object_layers: list[Path] + ) -> PhysicalMetadata: + self._physical_port.load(self._physical_handle) + try: + return self._physical_port.extract(composite_image, object_layers) + finally: + self._physical_port.unload() + + def _run_phase2( + self, composite_image: Path, physical: PhysicalMetadata + ) -> LogicalStructure: + self._logical_port.load(self._logical_handle) + try: + return self._logical_port.extract(composite_image, physical) + finally: + self._logical_port.unload() + + def _run_phase3(self, composite_image: Path) -> VisualVerification: + self._visual_port.load(self._visual_handle) + try: + return self._visual_port.verify(composite_image, self._sigma_levels) + finally: + self._visual_port.unload() + + def _run_phase4(self, data: ValidatorInput) -> VerificationBundle: + """순수 계산 — 모델 로드 없음.""" + physical, logical, visual = data.physical, data.logical, data.visual + w = self._weights + + # 모든 Phase 에 걸쳐 등장한 오브젝트 ID 합집합 + all_obj_ids: set[str] = ( + set(physical.occlusion_map) + | set(logical.degree_map) + | set(visual.sigma_threshold_map) + ) + + # degree 정규화를 위한 최댓값 + max_degree = max((logical.degree_map.get(oid, 0) for oid in all_obj_ids), default=1) + max_degree = max(max_degree, 1) + + answer_obj_metrics: list[dict] = [] + per_obj_perception: list[float] = [] + per_obj_logical: list[float] = [] + + for obj_id in sorted(all_obj_ids): + metrics = { + "occlusion_ratio": physical.occlusion_map.get(obj_id, 0.0), + "sigma_threshold": visual.sigma_threshold_map.get(obj_id, 16.0), + "degree": logical.degree_map.get(obj_id, 0), + "degree_norm": logical.degree_map.get(obj_id, 0) / max_degree, + "z_depth_hop": physical.z_depth_hop_map.get(obj_id, 0), + "neighbor_count": physical.cluster_density_map.get(obj_id, 0), + "hop": logical.hop_map.get(obj_id, 0), + "diameter": logical.diameter, + "detail_retention_rate": visual.detail_retention_rate_map.get(obj_id, 1.0), + } + if resolve_answer(metrics): + answer_obj_metrics.append(metrics) + p_score, l_score, _ = integrate_verification_v2(metrics, self._pass_threshold, w) + per_obj_perception.append(p_score) + per_obj_logical.append(l_score) + + # Scene 단위 집계 + avg_perception = ( + sum(per_obj_perception) / len(per_obj_perception) if per_obj_perception else 0.0 + ) + avg_logical = ( + sum(per_obj_logical) / len(per_obj_logical) if per_obj_logical else 0.0 + ) + total_score = avg_perception * w.total_perception + avg_logical * w.total_logical + passed = total_score >= self._pass_threshold + + difficulty = compute_scene_difficulty(answer_obj_metrics, w) + + return VerificationBundle( + perception=VerificationResult( + score=avg_perception, + **{"pass": passed}, + signals={ + "sigma_threshold_map": visual.sigma_threshold_map, + "detail_retention_rate_map": visual.detail_retention_rate_map, + }, + ), + logical=VerificationResult( + score=avg_logical, + **{"pass": passed}, + signals={ + "degree_map": logical.degree_map, + "hop_map": logical.hop_map, + "diameter": logical.diameter, + "answer_obj_count": len(answer_obj_metrics), + "scene_difficulty": difficulty, + }, + ), + final=FinalVerification( + total_score=total_score, + **{"pass": passed}, + failure_reason="" if passed else "difficulty_too_low", + ), + ) + + +# ------------------------------------------------------------------ +# 편의 함수 +# ------------------------------------------------------------------ + +def run_validator( + composite_image: Path, + object_layers: list[Path], + orchestrator: ValidatorOrchestrator, +) -> VerificationBundle: + return orchestrator.run(composite_image, object_layers) diff --git a/src/discoverex/bootstrap/__init__.py b/src/discoverex/bootstrap/__init__.py index d324c08..0c3445a 100644 --- a/src/discoverex/bootstrap/__init__.py +++ b/src/discoverex/bootstrap/__init__.py @@ -1,5 +1,5 @@ from .config_defaults import resolve_config from .context import AppContext -from .factory import build_context +from .factory import build_context, build_validator_context -__all__ = ["AppContext", "build_context", "resolve_config"] +__all__ = ["AppContext", "build_context", "build_validator_context", "resolve_config"] diff --git a/src/discoverex/bootstrap/factory.py b/src/discoverex/bootstrap/factory.py index 0cdf74f..ad8dcec 100644 --- a/src/discoverex/bootstrap/factory.py +++ b/src/discoverex/bootstrap/factory.py @@ -5,7 +5,11 @@ from hydra.utils import instantiate -from discoverex.config import PipelineConfig +from discoverex.adapters.outbound.bundle_store import LocalJsonBundleStore +from discoverex.application.use_cases.validator import ValidatorOrchestrator +from discoverex.config import PipelineConfig, ValidatorPipelineConfig +from discoverex.domain.services.verification import ScoringWeights +from discoverex.models.types import ModelHandle from .config_defaults import resolve_config from .context import AppContext @@ -58,3 +62,67 @@ def build_context(config: PipelineConfig | dict[str, Any] | None = None) -> AppC thresholds=cfg.thresholds, model_versions=cfg.model_versions, ) + + +def _make_handle(name: str, cfg_dict: dict[str, Any]) -> ModelHandle: + return ModelHandle( + name=name, + version="v0", + runtime="hf", + model_id=str(cfg_dict.get("model_id", "")), + device=str(cfg_dict.get("device", "cuda")), + dtype=str(cfg_dict.get("dtype", "float16")), + ) + + +def _build_scoring_weights(cfg: ValidatorPipelineConfig) -> ScoringWeights: + """weights_path 가 지정되면 JSON 파일에서 로드, 아니면 cfg.weights 에서 빌드.""" + if cfg.weights_path is not None: + weights_file = Path(cfg.weights_path) + return ScoringWeights.model_validate_json( + weights_file.read_text(encoding="utf-8") + ) + return ScoringWeights(**cfg.weights.model_dump()) + + +def build_validator_context( + config: ValidatorPipelineConfig | dict[str, Any] | None = None, +) -> ValidatorOrchestrator: + cfg: ValidatorPipelineConfig + if isinstance(config, ValidatorPipelineConfig): + cfg = config + elif isinstance(config, dict): + cfg = ValidatorPipelineConfig.model_validate(config) + else: + raise ValueError("config must be a ValidatorPipelineConfig or dict") + + physical_port = instantiate(cfg.models.physical_extraction.as_kwargs()) + logical_port = instantiate(cfg.models.logical_extraction.as_kwargs()) + visual_port = instantiate(cfg.models.visual_verification.as_kwargs()) + + physical_handle = _make_handle( + "physical_extraction", cfg.models.physical_extraction.model_dump() + ) + logical_handle = _make_handle( + "logical_extraction", cfg.models.logical_extraction.model_dump() + ) + visual_handle = _make_handle( + "visual_verification", cfg.models.visual_verification.model_dump() + ) + + scoring_weights = _build_scoring_weights(cfg) + bundle_store = ( + LocalJsonBundleStore(Path(cfg.bundle_store_dir)) if cfg.bundle_store_dir else None + ) + + return ValidatorOrchestrator( + physical_port=physical_port, + logical_port=logical_port, + visual_port=visual_port, + physical_handle=physical_handle, + logical_handle=logical_handle, + visual_handle=visual_handle, + pass_threshold=cfg.thresholds.pass_threshold, + scoring_weights=scoring_weights, + bundle_store=bundle_store, + ) diff --git a/src/discoverex/config/__init__.py b/src/discoverex/config/__init__.py index e4ebb55..6a2c6de 100644 --- a/src/discoverex/config/__init__.py +++ b/src/discoverex/config/__init__.py @@ -8,6 +8,10 @@ RuntimeEnvConfig, RuntimeModelConfig, ThresholdsConfig, + ValidatorModelsConfig, + ValidatorPipelineConfig, + ValidatorThresholdsConfig, + ValidatorWeightsConfig, ) __all__ = [ @@ -20,4 +24,8 @@ "RuntimeEnvConfig", "RuntimeModelConfig", "ThresholdsConfig", + "ValidatorModelsConfig", + "ValidatorPipelineConfig", + "ValidatorThresholdsConfig", + "ValidatorWeightsConfig", ] diff --git a/src/discoverex/config/schema.py b/src/discoverex/config/schema.py index 71c58d4..8c6d317 100644 --- a/src/discoverex/config/schema.py +++ b/src/discoverex/config/schema.py @@ -89,6 +89,58 @@ class ModelVersionsConfig(BaseModel): fx: str = "fx-v0" +class ValidatorModelsConfig(BaseModel): + physical_extraction: HydraComponentConfig + logical_extraction: HydraComponentConfig + visual_verification: HydraComponentConfig + + +class ValidatorThresholdsConfig(BaseModel): + is_hidden_min_conditions: int = 2 + pass_threshold: float = 0.35 + + @field_validator("pass_threshold") + @classmethod + def validate_positive(cls, value: float) -> float: + if value < 0.0: + raise ValueError("value must be >= 0.0") + return value + + +class ValidatorWeightsConfig(BaseModel): + """ScoringWeights 의 config 레이어 쌍. + + YAML weights: 섹션 값을 파싱하며, 미지정 필드는 코드 기본값을 사용한다. + factory.py 에서 ScoringWeights(**cfg.weights.model_dump()) 로 변환된다. + """ + + perception_sigma: float = Field(0.50, ge=0.0) + perception_drr: float = Field(0.50, ge=0.0) + logical_hop: float = Field(0.55, ge=0.0) + logical_degree: float = Field(0.45, ge=0.0) + total_perception: float = Field(0.45, ge=0.0) + total_logical: float = Field(0.55, ge=0.0) + difficulty_occlusion: float = Field(0.25, ge=0.0) + difficulty_sigma: float = Field(0.20, ge=0.0) + difficulty_hop: float = Field(0.20, ge=0.0) + difficulty_degree: float = Field(0.15, ge=0.0) + difficulty_drr: float = Field(0.20, ge=0.0) + difficulty_interaction: float = Field(0.10, ge=0.0) + + +class ValidatorPipelineConfig(BaseModel): + model_config = ConfigDict(extra="forbid") + + models: ValidatorModelsConfig + adapters: AdaptersConfig + runtime: RuntimeConfig = Field(default_factory=RuntimeConfig) + thresholds: ValidatorThresholdsConfig = Field(default_factory=ValidatorThresholdsConfig) + weights: ValidatorWeightsConfig = Field(default_factory=ValidatorWeightsConfig) + weights_path: str | None = None # 지정 시 JSON 파일에서 로드 → weights 필드 무시 + bundle_store_dir: str | None = None # VerificationBundle 저장 디렉터리 (null 이면 미저장) + model_versions: ModelVersionsConfig = Field(default_factory=ModelVersionsConfig) + + class PipelineConfig(BaseModel): model_config = ConfigDict(extra="forbid") diff --git a/src/discoverex/config_loader.py b/src/discoverex/config_loader.py index 25f5f45..405e8ce 100644 --- a/src/discoverex/config_loader.py +++ b/src/discoverex/config_loader.py @@ -5,7 +5,7 @@ from hydra import compose, initialize_config_dir from omegaconf import OmegaConf -from discoverex.config import PipelineConfig +from discoverex.config import PipelineConfig, ValidatorPipelineConfig def load_pipeline_config( @@ -20,3 +20,17 @@ def load_pipeline_config( if not isinstance(data, dict): raise ValueError("Hydra config must resolve to dict") return PipelineConfig.model_validate(data) + + +def load_validator_config( + config_name: str = "validator", + config_dir: str | Path = "conf", + overrides: list[str] | None = None, +) -> ValidatorPipelineConfig: + cfg_dir = Path(config_dir).resolve() + with initialize_config_dir(config_dir=str(cfg_dir), version_base=None): + cfg = compose(config_name=config_name, overrides=overrides or []) + data = OmegaConf.to_container(cfg, resolve=True) + if not isinstance(data, dict): + raise ValueError("Hydra config must resolve to dict") + return ValidatorPipelineConfig.model_validate(data) diff --git a/src/discoverex/domain/region.py b/src/discoverex/domain/region.py index f316c77..7ced01a 100644 --- a/src/discoverex/domain/region.py +++ b/src/discoverex/domain/region.py @@ -21,6 +21,12 @@ class Geometry(BaseModel): type: GeometryType = GeometryType.BBOX bbox: BBox mask_ref: str | None = None + # Physical metadata populated by Phase 1 (MobileSAM) + z_index: int = 0 + occlusion_ratio: float = 0.0 + z_depth_hop: int = 0 + neighbor_count: int = 0 + euclidean_distances: list[float] = Field(default_factory=list) class RegionRole(str, Enum): diff --git a/src/discoverex/domain/services/verification.py b/src/discoverex/domain/services/verification.py index bf197eb..03e7065 100644 --- a/src/discoverex/domain/services/verification.py +++ b/src/discoverex/domain/services/verification.py @@ -1,9 +1,42 @@ from __future__ import annotations +from pydantic import BaseModel, Field + from discoverex.domain.scene import Scene from discoverex.domain.verification import FinalVerification, VerificationResult +class ScoringWeights(BaseModel): + """Phase-4 스코어링 수식의 모든 가중치를 집약한 모델. + + 모든 필드는 float 이며 외부에서 주입하거나 WeightFitter 로 최적화할 수 있다. + + 설계 원칙 + ---------- + * perception / logical sub-score 는 각자의 가중치 합으로 나누어 정규화하므로 + 각 컴포넌트의 최댓값은 1.0 이 된다. + * total_perception + total_logical = 1.0 이면 total_score 의 최댓값도 1.0. + * difficulty_* 는 정규화 없이 절댓값을 그대로 사용한다 (D(obj) 스케일 유지). + """ + + # integrate_verification_v2 — perception sub-score + perception_sigma: float = Field(0.50, ge=0.0) + perception_drr: float = Field(0.50, ge=0.0) + # integrate_verification_v2 — logical sub-score + logical_hop: float = Field(0.55, ge=0.0) + logical_degree: float = Field(0.45, ge=0.0) + # integrate_verification_v2 — total 집계 (합이 1.0 이어야 max=1.0) + total_perception: float = Field(0.45, ge=0.0) + total_logical: float = Field(0.55, ge=0.0) + # compute_difficulty D(obj) 항별 가중치 (정규화 없음) + difficulty_occlusion: float = Field(0.25, ge=0.0) + difficulty_sigma: float = Field(0.20, ge=0.0) + difficulty_hop: float = Field(0.20, ge=0.0) + difficulty_degree: float = Field(0.15, ge=0.0) + difficulty_drr: float = Field(0.20, ge=0.0) + difficulty_interaction: float = Field(0.10, ge=0.0) + + def run_logical_verification(scene: Scene, pass_threshold: float) -> VerificationResult: answer_count = len(scene.answer.answer_region_ids) unique_ok = answer_count == 1 and scene.answer.uniqueness_intent @@ -34,3 +67,104 @@ def integrate_verification( pass_=passed, failure_reason=failure_reason, ) + + +# --------------------------------------------------------------------------- +# Validator pipeline — Phase 4 순수 계산 함수 +# 모든 입력은 dict 로 받아 도메인 레이어가 모델 타입에 의존하지 않도록 한다. +# --------------------------------------------------------------------------- + +def resolve_answer(obj_metrics: dict) -> bool: + """5개 은닉 조건 중 2개 이상 충족 시 True 반환. + + obj_metrics 키: occlusion_ratio, sigma_threshold, degree, + z_depth_hop, neighbor_count + """ + conditions = [ + obj_metrics.get("occlusion_ratio", 0.0) > 0.3, + obj_metrics.get("sigma_threshold", 16.0) <= 4, + obj_metrics.get("degree", 0) >= 3, + obj_metrics.get("z_depth_hop", 0) >= 2, + obj_metrics.get("neighbor_count", 0) >= 3, + ] + return sum(conditions) >= 2 + + +def compute_difficulty( + obj_metrics: dict, weights: ScoringWeights | None = None +) -> float: + """D(obj) 난이도 점수 계산. + + D(obj) = w_occ · occlusion² + + w_sig · (1 / σ_threshold) + + w_hop · (hop / diameter) + + w_deg · degree_norm² + + w_drr · (1 - DRR) + + w_ix · occlusion · (hop / diameter) + + obj_metrics 키: occlusion_ratio, sigma_threshold, hop, diameter, + degree_norm, detail_retention_rate + """ + w = weights or ScoringWeights() + occlusion = obj_metrics.get("occlusion_ratio", 0.0) + sigma = max(obj_metrics.get("sigma_threshold", 1.0), 1e-6) + hop = obj_metrics.get("hop", 0) + diameter = max(obj_metrics.get("diameter", 1.0), 1e-6) + degree_n = obj_metrics.get("degree_norm", 0.0) + drr = obj_metrics.get("detail_retention_rate", 1.0) + + return ( + w.difficulty_occlusion * occlusion ** 2 + + w.difficulty_sigma * (1.0 / sigma) + + w.difficulty_hop * (hop / diameter) + + w.difficulty_degree * degree_n ** 2 + + w.difficulty_drr * (1.0 - drr) + + w.difficulty_interaction * occlusion * (hop / diameter) + ) + + +def compute_scene_difficulty( + answer_objs: list[dict], weights: ScoringWeights | None = None +) -> float: + """Scene_Difficulty = (1 / |answer|) · Σ D(obj) (obj ∈ answer)""" + if not answer_objs: + return 0.0 + return sum(compute_difficulty(obj, weights) for obj in answer_objs) / len(answer_objs) + + +def integrate_verification_v2( + obj_metrics: dict, + pass_threshold: float = 0.35, + weights: ScoringWeights | None = None, +) -> tuple[float, float, float]: + """오브젝트 하나에 대한 정규화된 perception / logical / total 점수 계산. + + perception = (w_σ · (1/σ) + w_drr · (1−DRR)) / (w_σ + w_drr) ∈ [0, 1] + logical = (w_hop · (hop/d) + w_deg · deg²) / (w_hop + w_deg) ∈ [0, 1] + total = perception · w_p + logical · w_l ∈ [0, 1] + + 각 sub-score 를 자신의 가중치 합으로 나누어 정규화하므로 가중치의 절댓값이 + 아닌 비율만이 점수에 영향을 준다. total 의 최댓값은 w_p + w_l 이며, + 두 값의 합이 1.0 이면 total 최댓값도 1.0 이 된다. + + 반환: (perception_score, logical_score, total_score) + """ + w = weights or ScoringWeights() + sigma = max(obj_metrics.get("sigma_threshold", 1.0), 1e-6) + drr = obj_metrics.get("detail_retention_rate", 1.0) + hop = obj_metrics.get("hop", 0) + diameter = max(obj_metrics.get("diameter", 1.0), 1e-6) + degree_n = obj_metrics.get("degree_norm", 0.0) + + p_denom = max(w.perception_sigma + w.perception_drr, 1e-9) + perception = ( + w.perception_sigma * (1.0 / sigma) + w.perception_drr * (1.0 - drr) + ) / p_denom + + l_denom = max(w.logical_hop + w.logical_degree, 1e-9) + logical = ( + w.logical_hop * (hop / diameter) + w.logical_degree * degree_n ** 2 + ) / l_denom + + total = perception * w.total_perception + logical * w.total_logical + return perception, logical, total diff --git a/src/discoverex/models/types.py b/src/discoverex/models/types.py index c2acf21..024fa40 100644 --- a/src/discoverex/models/types.py +++ b/src/discoverex/models/types.py @@ -77,3 +77,38 @@ class InpaintPrediction(TypedDict, total=False): patch_image_ref: str composited_image_ref: str inpaint_mode: str + + +# --------------------------------------------------------------------------- +# Validator pipeline types (Phase 1–4 inter-phase data contracts) +# --------------------------------------------------------------------------- + +class PhysicalMetadata(BaseModel): + """Phase 1 output: MobileSAM segmentation + geometric analysis.""" + regions: list[dict[str, Any]] = Field(default_factory=list) + occlusion_map: dict[str, float] = Field(default_factory=dict) + z_index_map: dict[str, int] = Field(default_factory=dict) + z_depth_hop_map: dict[str, int] = Field(default_factory=dict) + cluster_density_map: dict[str, int] = Field(default_factory=dict) + euclidean_distance_map: dict[str, list[float]] = Field(default_factory=dict) + + +class LogicalStructure(BaseModel): + """Phase 2 output: Moondream2 scene graph + NetworkX graph metrics.""" + relations: list[dict[str, Any]] = Field(default_factory=list) + degree_map: dict[str, int] = Field(default_factory=dict) + hop_map: dict[str, int] = Field(default_factory=dict) + diameter: float = 1.0 + + +class VisualVerification(BaseModel): + """Phase 3 output: YOLO sigma threshold + CLIP detail retention rate.""" + sigma_threshold_map: dict[str, float] = Field(default_factory=dict) + detail_retention_rate_map: dict[str, float] = Field(default_factory=dict) + + +class ValidatorInput(BaseModel): + """Aggregated input for Phase 4 pure computation.""" + physical: PhysicalMetadata + logical: LogicalStructure + visual: VisualVerification diff --git a/src/sample/layers/sample_1/obj_00.png b/src/sample/layers/sample_1/obj_00.png new file mode 100644 index 0000000..f35a967 Binary files /dev/null and b/src/sample/layers/sample_1/obj_00.png differ diff --git a/src/sample/layers/sample_1/obj_01.png b/src/sample/layers/sample_1/obj_01.png new file mode 100644 index 0000000..94c50a6 Binary files /dev/null and b/src/sample/layers/sample_1/obj_01.png differ diff --git a/src/sample/layers/sample_1/obj_02.png b/src/sample/layers/sample_1/obj_02.png new file mode 100644 index 0000000..24fbb2e Binary files /dev/null and b/src/sample/layers/sample_1/obj_02.png differ diff --git a/src/sample/layers/sample_1/obj_03.png b/src/sample/layers/sample_1/obj_03.png new file mode 100644 index 0000000..d80959a Binary files /dev/null and b/src/sample/layers/sample_1/obj_03.png differ diff --git a/src/sample/layers/sample_1/obj_04.png b/src/sample/layers/sample_1/obj_04.png new file mode 100644 index 0000000..dd3c669 Binary files /dev/null and b/src/sample/layers/sample_1/obj_04.png differ diff --git a/src/sample/layers/sample_1/obj_05.png b/src/sample/layers/sample_1/obj_05.png new file mode 100644 index 0000000..d8bd762 Binary files /dev/null and b/src/sample/layers/sample_1/obj_05.png differ diff --git a/src/sample/layers/sample_2/obj_00.png b/src/sample/layers/sample_2/obj_00.png new file mode 100644 index 0000000..0602c15 Binary files /dev/null and b/src/sample/layers/sample_2/obj_00.png differ diff --git a/src/sample/layers/sample_2/obj_01.png b/src/sample/layers/sample_2/obj_01.png new file mode 100644 index 0000000..7cce79b Binary files /dev/null and b/src/sample/layers/sample_2/obj_01.png differ diff --git a/src/sample/layers/sample_2/obj_02.png b/src/sample/layers/sample_2/obj_02.png new file mode 100644 index 0000000..834765d Binary files /dev/null and b/src/sample/layers/sample_2/obj_02.png differ diff --git a/src/sample/layers/sample_2/obj_03.png b/src/sample/layers/sample_2/obj_03.png new file mode 100644 index 0000000..61fd0c1 Binary files /dev/null and b/src/sample/layers/sample_2/obj_03.png differ diff --git a/src/sample/layers/sample_2/obj_04.png b/src/sample/layers/sample_2/obj_04.png new file mode 100644 index 0000000..8cba73f Binary files /dev/null and b/src/sample/layers/sample_2/obj_04.png differ diff --git a/src/sample/layers/sample_2/obj_05.png b/src/sample/layers/sample_2/obj_05.png new file mode 100644 index 0000000..71b945c Binary files /dev/null and b/src/sample/layers/sample_2/obj_05.png differ diff --git a/src/sample/layers/sample_3/obj_00.png b/src/sample/layers/sample_3/obj_00.png new file mode 100644 index 0000000..8774b02 Binary files /dev/null and b/src/sample/layers/sample_3/obj_00.png differ diff --git a/src/sample/layers/sample_3/obj_01.png b/src/sample/layers/sample_3/obj_01.png new file mode 100644 index 0000000..a025df1 Binary files /dev/null and b/src/sample/layers/sample_3/obj_01.png differ diff --git a/src/sample/layers/sample_3/obj_02.png b/src/sample/layers/sample_3/obj_02.png new file mode 100644 index 0000000..b96521b Binary files /dev/null and b/src/sample/layers/sample_3/obj_02.png differ diff --git a/src/sample/layers/sample_3/obj_03.png b/src/sample/layers/sample_3/obj_03.png new file mode 100644 index 0000000..3659971 Binary files /dev/null and b/src/sample/layers/sample_3/obj_03.png differ diff --git a/src/sample/layers/sample_3/obj_04.png b/src/sample/layers/sample_3/obj_04.png new file mode 100644 index 0000000..689861f Binary files /dev/null and b/src/sample/layers/sample_3/obj_04.png differ diff --git a/src/sample/layers/sample_3/obj_05.png b/src/sample/layers/sample_3/obj_05.png new file mode 100644 index 0000000..f30fed6 Binary files /dev/null and b/src/sample/layers/sample_3/obj_05.png differ diff --git a/src/sample/sample_1.png b/src/sample/sample_1.png new file mode 100644 index 0000000..1833822 Binary files /dev/null and b/src/sample/sample_1.png differ diff --git a/src/sample/sample_2.png b/src/sample/sample_2.png new file mode 100644 index 0000000..5ccb32b Binary files /dev/null and b/src/sample/sample_2.png differ diff --git a/src/sample/sample_3.png b/src/sample/sample_3.png new file mode 100644 index 0000000..7b8df76 Binary files /dev/null and b/src/sample/sample_3.png differ diff --git a/src/sample/test_samples.py b/src/sample/test_samples.py new file mode 100644 index 0000000..4408784 --- /dev/null +++ b/src/sample/test_samples.py @@ -0,0 +1,393 @@ +#!/usr/bin/env python3 +"""test_samples.py — PNG 샘플 이미지로 Validator 파이프라인 테스트 + +알파채널 생성 방식 +------------------ +1. PIL quantize(색상 군집화)로 이미지를 N개 색 영역으로 분할 +2. 각 영역 마스크를 MaxFilter로 팽창 → 인접 레이어 간 overlap 생성 +3. 레이어 순서(Z-index)가 낮은 레이어를 높은 레이어가 가리는 구조 + +occlusion 계산 (개선) +--------------------- +단일 RGB 이미지에는 투명도가 없으므로, 레이어 간 overlap(dilation)으로 +"위 레이어가 아래 레이어를 가리는 비율"을 직접 시뮬레이션한다. + +실행: + cd engine + python src/sample/test_samples.py + python src/sample/test_samples.py --n-clusters 8 --dilation 15 +""" +from __future__ import annotations + +import argparse +import math +import shutil +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import networkx as nx +import numpy as np +from PIL import Image, ImageFilter + +from discoverex.application.use_cases.validator import ValidatorOrchestrator +from discoverex.domain.services.verification import ScoringWeights +from discoverex.models.types import ( + LogicalStructure, + ModelHandle, + PhysicalMetadata, + VisualVerification, +) + +# --------------------------------------------------------------------------- +# 알파채널(레이어) 생성 +# --------------------------------------------------------------------------- + +def make_alpha_layers( + composite_path: Path, + n_clusters: int = 6, + dilation_px: int = 10, + min_area_ratio: float = 0.01, +) -> tuple[list[Path], list[np.ndarray]]: + """색상 군집화 + 마스크 팽창으로 RGBA 레이어 PNG 생성. + + Returns: + layer_paths : 저장된 PNG 파일 경로 목록 (Z-index 오름차순) + layer_arrays: 각 레이어의 numpy RGBA 배열 목록 + """ + out_dir = composite_path.parent / "layers" / composite_path.stem + if out_dir.exists(): + shutil.rmtree(out_dir) + out_dir.mkdir(parents=True) + + img = Image.open(composite_path).convert("RGB") + arr = np.array(img) + h, w = arr.shape[:2] + + # 색상 군집화 (PIL median-cut) + quantized = img.quantize(colors=n_clusters, method=Image.Quantize.MEDIANCUT) + indices = np.array(quantized) # shape (H, W) + + layer_paths: list[Path] = [] + layer_arrays: list[np.ndarray] = [] + kernel_size = dilation_px * 2 + 1 # MaxFilter는 홀수 크기 + + for cluster_id in sorted(np.unique(indices)): + raw_mask = (indices == cluster_id) + if raw_mask.sum() < h * w * min_area_ratio: + continue # 너무 작은 영역 스킵 + + # 마스크 팽창 → 인접 레이어와 overlap 생성 + mask_img = Image.fromarray((raw_mask * 255).astype(np.uint8)) + dilated_img = mask_img.filter(ImageFilter.MaxFilter(size=kernel_size)) + dilated = np.array(dilated_img) > 128 + + rgba = np.zeros((h, w, 4), dtype=np.uint8) + rgba[:, :, :3] = arr + rgba[:, :, 3] = (dilated * 255).astype(np.uint8) + + idx = len(layer_paths) + out_path = out_dir / f"obj_{idx:02d}.png" + Image.fromarray(rgba, mode="RGBA").save(out_path) + + orig_px = int(raw_mask.sum()) + dil_px = int(dilated.sum()) + print(f" obj_{idx:02d}.png 원본={orig_px:,}px → 팽창={dil_px:,}px") + + layer_paths.append(out_path) + layer_arrays.append(rgba) + + return layer_paths, layer_arrays + + +# --------------------------------------------------------------------------- +# PHASE 1 — CPU-only 어댑터 (MobileSAM 없이) +# --------------------------------------------------------------------------- + +class CpuPhysicalAdapter: + """alpha overlap 기반 물리 메타데이터 실계산. + + occlusion_ratio: + 단일 RGB 이미지 한계를 우회하여, 레이어 Z-order 상 + '위 레이어가 아래 레이어 alpha 영역을 가리는 비율'로 계산. + (j > i 인 레이어가 i 의 영역을 덮는 픽셀 수 / i 의 총 픽셀 수) + + z_depth_hop: Z-graph BFS (overlap 있으면 edge 생성) + cluster_density: center 간 거리 + cluster_radius_factor 적용 + """ + + def __init__( + self, + layer_arrays: list[np.ndarray], + cluster_radius_factor: float = 0.5, + ) -> None: + self._pre_arrays = layer_arrays + self._factor = cluster_radius_factor + self.last_result: PhysicalMetadata | None = None + + def load(self, handle: ModelHandle) -> None: + print(" [PHASE 1] CPU-only — occlusion/z_hop/density 실계산") + + def extract( + self, composite_image: Path, object_layers: list[Path] + ) -> PhysicalMetadata: + layer_arrays = self._pre_arrays + n = len(layer_arrays) + composite = np.array(Image.open(composite_image).convert("RGBA")) + h, w = composite.shape[:2] + + alphas = [la[:, :, 3] > 0 for la in layer_arrays] + + occlusion_map: dict[str, float] = {} + z_index_map: dict[str, int] = {} + z_depth_hop_map: dict[str, int] = {} + centers: dict[str, tuple[float, float]] = {} + cluster_density_map: dict[str, int] = {} + euclidean_distance_map: dict[str, list[float]] = {} + regions: list[dict] = [] + + for i, path in enumerate(object_layers): + obj_id = path.stem + alpha_i = alphas[i] + total_px = int(alpha_i.sum()) + z_index_map[obj_id] = i + + if total_px == 0: + occlusion_map[obj_id] = 0.0 + z_depth_hop_map[obj_id] = 0 + continue + + # occlusion: i 위에 있는 레이어(j > i)가 가리는 픽셀 비율 + covered = np.zeros((h, w), dtype=bool) + for j in range(i + 1, n): + covered |= alphas[j] + occluded_px = int((alpha_i & covered).sum()) + occlusion_map[obj_id] = occluded_px / total_px + + ys, xs = np.where(alpha_i) + cx, cy = float(xs.mean()), float(ys.mean()) + centers[obj_id] = (cx, cy) + + x1, y1 = int(xs.min()), int(ys.min()) + x2, y2 = int(xs.max()), int(ys.max()) + regions.append({ + "obj_id": obj_id, + "bbox": [x1 / w, y1 / h, (x2 - x1) / w, (y2 - y1) / h], + "center": [cx / w, cy / h], + "area": float(alpha_i.sum()) / (w * h), + }) + + # Z-depth hop: overlap → directed edge (j is above i → j→i) + g: nx.DiGraph = nx.DiGraph() + for path in object_layers: + g.add_node(path.stem) + for i in range(n): + for j in range(i + 1, n): + if (alphas[i] & alphas[j]).any(): + g.add_edge(object_layers[j].stem, object_layers[i].stem) + + roots = [nd for nd in g.nodes if g.in_degree(nd) == 0] or list(g.nodes)[:1] + for path in object_layers: + obj_id = path.stem + hops = [] + for root in roots: + try: + hops.append(nx.shortest_path_length(g, root, obj_id)) + except nx.NetworkXNoPath: + pass + z_depth_hop_map[obj_id] = max(hops, default=0) + + # Cluster density + obj_ids = list(centers.keys()) + for obj_id, (cx, cy) in centers.items(): + dists = [ + math.hypot(cx - centers[oid][0], cy - centers[oid][1]) + for oid in obj_ids if oid != obj_id + ] + euclidean_distance_map[obj_id] = dists + mean_dist = sum(dists) / len(dists) if dists else float("inf") + radius = mean_dist * self._factor + cluster_density_map[obj_id] = sum(1 for d in dists if d <= radius) + + result = PhysicalMetadata( + regions=regions, + occlusion_map=occlusion_map, + z_index_map=z_index_map, + z_depth_hop_map=z_depth_hop_map, + cluster_density_map=cluster_density_map, + euclidean_distance_map=euclidean_distance_map, + ) + self.last_result = result + return result + + def unload(self) -> None: + print(" [PHASE 1] 완료") + + +# --------------------------------------------------------------------------- +# PHASE 2 — 스마트 더미 +# --------------------------------------------------------------------------- + +class SmartLogicalAdapter: + def load(self, handle: ModelHandle) -> None: + print(" [PHASE 2] SmartDummy — Moondream2 생략") + + def extract( + self, composite_image: Path, physical: PhysicalMetadata + ) -> LogicalStructure: + obj_ids = sorted(physical.occlusion_map.keys()) + n = max(len(obj_ids), 1) + return LogicalStructure( + relations=[], + degree_map={oid: min(n - 1, 4) for oid in obj_ids}, + hop_map={oid: min(i + 1, 4) for i, oid in enumerate(obj_ids)}, + diameter=float(max(n, 2)), + ) + + def unload(self) -> None: + print(" [PHASE 2] 완료") + + +# --------------------------------------------------------------------------- +# PHASE 3 — 스마트 더미 +# --------------------------------------------------------------------------- + +class SmartVisualAdapter: + """physical 결과의 obj_id 목록을 참조해 시각 지표 생성.""" + + def __init__(self, physical_adapter: CpuPhysicalAdapter) -> None: + self._physical = physical_adapter + + def load(self, handle: ModelHandle) -> None: + print(" [PHASE 3] SmartDummy — YOLO+CLIP 생략 (sigma=8.0, DRR=0.75)") + + def verify( + self, composite_image: Path, sigma_levels: list[float] + ) -> VisualVerification: + result = self._physical.last_result + obj_ids = list(result.occlusion_map.keys()) if result else ["obj_00"] + return VisualVerification( + sigma_threshold_map={oid: 8.0 for oid in obj_ids}, + detail_retention_rate_map={oid: 0.75 for oid in obj_ids}, + ) + + def unload(self) -> None: + print(" [PHASE 3] 완료") + + +# --------------------------------------------------------------------------- +# 출력 +# --------------------------------------------------------------------------- + +def _bar(c: str = "-", n: int = 64) -> str: + return c * n + + +def _print_phase1(result: PhysicalMetadata) -> None: + print(_bar()) + print("PHASE 1 실계산값") + print(_bar()) + print(f" {'obj_id':<12} {'occlusion':>10} {'z_depth_hop':>12} {'neighbor_cnt':>13}") + print(" " + _bar("-", 50)) + for oid in sorted(result.occlusion_map): + print( + f" {oid:<12}" + f" {result.occlusion_map[oid]:>10.3f}" + f" {result.z_depth_hop_map.get(oid, 0):>12}" + f" {result.cluster_density_map.get(oid, 0):>13}" + ) + + +def _print_bundle(bundle) -> None: # type: ignore[annotation-unchecked] + sigs = bundle.logical.signals + psigs = bundle.perception.signals + print(_bar("=")) + print(f" pass : {bundle.final.pass_}") + print(f" total_score : {bundle.final.total_score:.4f} (기준 0.23)") + print(f" perception score : {bundle.perception.score:.4f}") + print(f" logical score : {bundle.logical.score:.4f}") + print(_bar()) + print(f" answer_obj_count : {sigs['answer_obj_count']} (숨어있다고 판단된 객체)") + print(f" scene_difficulty : {sigs['scene_difficulty']:.4f}") + print(f" degree_map : {sigs['degree_map']}") + print(f" hop_map : {sigs['hop_map']}") + print(f" sigma_map : {psigs.get('sigma_threshold_map', {})}") + print(f" drr_map : {psigs.get('detail_retention_rate_map', {})}") + + +# --------------------------------------------------------------------------- +# 이미지 1개 처리 +# --------------------------------------------------------------------------- + +def process_image( + composite_path: Path, + n_clusters: int, + dilation_px: int, +) -> None: + print() + print(_bar("=")) + print(f" 이미지: {composite_path.name}") + print(_bar("=")) + + print(f"\n [레이어 생성] n_clusters={n_clusters}, dilation={dilation_px}px") + layer_paths, layer_arrays = make_alpha_layers( + composite_path, n_clusters=n_clusters, dilation_px=dilation_px + ) + print(f" 총 {len(layer_paths)}개 레이어 → {layer_paths[0].parent}") + + handle = ModelHandle(name="cpu_test", version="v0", runtime="cpu") + physical_adapter = CpuPhysicalAdapter(layer_arrays) + visual_adapter = SmartVisualAdapter(physical_adapter) + + orch = ValidatorOrchestrator( + physical_port=physical_adapter, + logical_port=SmartLogicalAdapter(), + visual_port=visual_adapter, + physical_handle=handle, + logical_handle=handle, + visual_handle=handle, + pass_threshold=0.23, + scoring_weights=ScoringWeights(), + ) + + print() + bundle = orch.run(composite_image=composite_path, object_layers=layer_paths) + + print() + _print_phase1(physical_adapter.last_result) # type: ignore[arg-type] + print() + _print_bundle(bundle) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser(description="PNG 샘플 이미지 Validator 테스트") + parser.add_argument("--n-clusters", type=int, default=6, + help="색상 군집 수 = 생성할 레이어 수 (기본 6)") + parser.add_argument("--dilation", type=int, default=10, + help="마스크 팽창 픽셀 수 (기본 10, 클수록 overlap↑)") + args = parser.parse_args() + + sample_dir = Path(__file__).parent + images = sorted(sample_dir.glob("sample_*.png")) + + if not images: + print(f"[ERROR] PNG 파일 없음: {sample_dir}", file=sys.stderr) + sys.exit(1) + + print(f"샘플 이미지 {len(images)}개 발견") + for img_path in images: + process_image(img_path, n_clusters=args.n_clusters, dilation_px=args.dilation) + + print() + print(_bar("=")) + print("완료") + print(_bar("=")) + + +if __name__ == "__main__": + main() diff --git a/tests/test_validator_e2e.py b/tests/test_validator_e2e.py new file mode 100644 index 0000000..5ebc8e3 --- /dev/null +++ b/tests/test_validator_e2e.py @@ -0,0 +1,403 @@ +""" +Validator 파이프라인 End-to-End 테스트 + +테스트 전략 +----------- +* Phase 1 (MobileSAM) / Phase 2 (Moondream2) / Phase 3 (YOLO+CLIP) 는 + 실제 모델 가중치와 GPU 없이 실행 불가 → Dummy 어댑터 사용 +* Dummy 어댑터가 반환하는 고정값을 직접 추적해 Phase 4 수치를 사전 계산하고, + 실제 실행 결과와 비교한다 (단순 타입 검사가 아닌 수치 검증). + +Dummy 어댑터 고정 출력값 +-------------------------- + DummyPhysical : occlusion=0.45, z_depth_hop=2, cluster_density=3 + DummyLogical : degree=3, hop=2, diameter=4.0 + DummyVisual : sigma=4.0, drr=0.55 (obj_0, obj_1 고정) + +Phase 4 수식 (ScoringWeights 기본값 기준 — 정규화 적용): + p_denom = 0.50+0.50 = 1.0 + perception = (0.50*(1/4) + 0.50*0.45) / 1.0 = 0.35 + l_denom = 0.55+0.45 = 1.0 + logical = (0.55*0.5 + 0.45*1.0) / 1.0 = 0.725 + total = 0.35*0.45 + 0.725*0.55 = 0.55625 → PASS +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest + +from discoverex.adapters.outbound.models.dummy import ( + DummyLogicalExtraction, + DummyPhysicalExtraction, + DummyVisualVerification, +) +from discoverex.application.use_cases.validator import ValidatorOrchestrator +from discoverex.domain.services.verification import ( + ScoringWeights, + compute_difficulty, + compute_scene_difficulty, + integrate_verification_v2, + resolve_answer, +) +from discoverex.domain.verification import VerificationBundle +from discoverex.models.types import ( + LogicalStructure, + ModelHandle, + PhysicalMetadata, + VisualVerification, +) + +# --------------------------------------------------------------------------- +# 헬퍼 +# --------------------------------------------------------------------------- + +def _make_handle(name: str) -> ModelHandle: + return ModelHandle(name=name, version="dummy", runtime="dummy") + + +def _make_png(path: Path, width: int = 64, height: int = 64) -> None: + try: + from PIL import Image + img = Image.new("RGBA", (width, height), color=(128, 128, 128, 255)) + img.save(path, format="PNG") + except ModuleNotFoundError: + path.write_bytes( + b"\x89PNG\r\n\x1a\n" + b"\x00\x00\x00\rIHDR" + b"\x00\x00\x00@\x00\x00\x00@\x08\x02\x00\x00\x00" + b"\x25\x3e\x45\x56" + b"\x00\x00\x00\x00IEND\xaeB`\x82" + ) + + +def _make_orchestrator( + physical_port: Any = None, + logical_port: Any = None, + visual_port: Any = None, + pass_threshold: float = 0.35, + scoring_weights: ScoringWeights | None = None, +) -> ValidatorOrchestrator: + return ValidatorOrchestrator( + physical_port=physical_port or DummyPhysicalExtraction(), + logical_port=logical_port or DummyLogicalExtraction(), + visual_port=visual_port or DummyVisualVerification(), + physical_handle=_make_handle("physical"), + logical_handle=_make_handle("logical"), + visual_handle=_make_handle("visual"), + pass_threshold=pass_threshold, + scoring_weights=scoring_weights, + ) + + +# --------------------------------------------------------------------------- +# 커스텀 어댑터 — "어려운" 시나리오 (max 점수 유도) +# --------------------------------------------------------------------------- + +class _HardVisualVerification: + """sigma=1.0, drr=0.0 → perception 최댓값.""" + def load(self, handle: ModelHandle) -> None: # noqa: ARG002 + pass + def verify(self, composite_image: Path, sigma_levels: list[float]) -> VisualVerification: # noqa: ARG002 + return VisualVerification( + sigma_threshold_map={"obj_0": 1.0, "obj_1": 1.0}, + detail_retention_rate_map={"obj_0": 0.0, "obj_1": 0.0}, + ) + def unload(self) -> None: + pass + + +class _HardLogicalExtraction: + """hop = diameter = 4, degree = 4 → logical 최댓값.""" + def load(self, handle: ModelHandle) -> None: # noqa: ARG002 + pass + def extract(self, composite_image: Path, physical: PhysicalMetadata) -> LogicalStructure: # noqa: ARG002 + obj_ids = list(physical.occlusion_map.keys()) or ["obj_0", "obj_1"] + return LogicalStructure( + relations=[], + degree_map={oid: 4 for oid in obj_ids}, + hop_map={oid: 4 for oid in obj_ids}, + diameter=4.0, + ) + def unload(self) -> None: + pass + + +# --------------------------------------------------------------------------- +# 사전 계산된 예상값 (ScoringWeights 기본값 / 정규화 수식 기준) +# --------------------------------------------------------------------------- + +_W = ScoringWeights() +_P_DENOM = _W.perception_sigma + _W.perception_drr # 1.0 +_L_DENOM = _W.logical_hop + _W.logical_degree # 1.0 + +# 표준 (dummy) 시나리오 +_PERC_STD = (_W.perception_sigma * (1.0 / 4.0) + _W.perception_drr * (1.0 - 0.55)) / _P_DENOM # 0.35 +_LOGI_STD = (_W.logical_hop * (2.0 / 4.0) + _W.logical_degree * 1.0 ** 2) / _L_DENOM # 0.725 +_TOT_STD = _PERC_STD * _W.total_perception + _LOGI_STD * _W.total_logical # 0.55625 + +# 어려운 시나리오 (sigma=1, drr=0, hop=diameter=4, degree=4) +_PERC_HARD = (_W.perception_sigma * 1.0 + _W.perception_drr * 1.0) / _P_DENOM # 1.0 +_LOGI_HARD = (_W.logical_hop * 1.0 + _W.logical_degree * 1.0) / _L_DENOM # 1.0 +_TOT_HARD = _PERC_HARD * _W.total_perception + _LOGI_HARD * _W.total_logical # 1.0 + + +# =========================================================================== +# 1. 전체 파이프라인 E2E — Dummy 어댑터 +# =========================================================================== + +class TestE2EFullPipelineDummy: + + @pytest.fixture() + def orch(self) -> ValidatorOrchestrator: + return _make_orchestrator() + + @pytest.fixture() + def composite(self, tmp_path: Path) -> Path: + p = tmp_path / "composite.png" + _make_png(p) + return p + + def test_returns_verification_bundle(self, orch: ValidatorOrchestrator, composite: Path, tmp_path: Path) -> None: + layers = [tmp_path / f"obj_{i}.png" for i in range(2)] + for p in layers: + _make_png(p) + assert isinstance(orch.run(composite_image=composite, object_layers=layers), VerificationBundle) + + def test_two_layers_perception_score(self, orch: ValidatorOrchestrator, composite: Path, tmp_path: Path) -> None: + layers = [tmp_path / f"obj_{i}.png" for i in range(2)] + for p in layers: + _make_png(p) + bundle = orch.run(composite_image=composite, object_layers=layers) + assert bundle.perception.score == pytest.approx(_PERC_STD, rel=1e-4) + + def test_two_layers_logical_score(self, orch: ValidatorOrchestrator, composite: Path, tmp_path: Path) -> None: + layers = [tmp_path / f"obj_{i}.png" for i in range(2)] + for p in layers: + _make_png(p) + bundle = orch.run(composite_image=composite, object_layers=layers) + assert bundle.logical.score == pytest.approx(_LOGI_STD, rel=1e-4) + + def test_two_layers_total_score(self, orch: ValidatorOrchestrator, composite: Path, tmp_path: Path) -> None: + layers = [tmp_path / f"obj_{i}.png" for i in range(2)] + for p in layers: + _make_png(p) + bundle = orch.run(composite_image=composite, object_layers=layers) + assert bundle.final.total_score == pytest.approx(_TOT_STD, rel=1e-4) + + def test_two_layers_passes_threshold(self, orch: ValidatorOrchestrator, composite: Path, tmp_path: Path) -> None: + layers = [tmp_path / f"obj_{i}.png" for i in range(2)] + for p in layers: + _make_png(p) + bundle = orch.run(composite_image=composite, object_layers=layers) + assert bundle.final.pass_ is True + assert bundle.final.failure_reason == "" + + def test_two_layers_answer_obj_count(self, orch: ValidatorOrchestrator, composite: Path, tmp_path: Path) -> None: + layers = [tmp_path / f"obj_{i}.png" for i in range(2)] + for p in layers: + _make_png(p) + bundle = orch.run(composite_image=composite, object_layers=layers) + assert bundle.logical.signals["answer_obj_count"] == 2 + + def test_two_layers_scene_difficulty_signal(self, orch: ValidatorOrchestrator, composite: Path, tmp_path: Path) -> None: + expected_d = compute_difficulty({ + "occlusion_ratio": 0.45, "sigma_threshold": 4.0, "hop": 2, + "diameter": 4.0, "degree_norm": 1.0, "detail_retention_rate": 0.55, + }) + layers = [tmp_path / f"obj_{i}.png" for i in range(2)] + for p in layers: + _make_png(p) + bundle = orch.run(composite_image=composite, object_layers=layers) + assert bundle.logical.signals["scene_difficulty"] == pytest.approx(expected_d, rel=1e-4) + + def test_required_signal_keys_present(self, orch: ValidatorOrchestrator, composite: Path, tmp_path: Path) -> None: + layers = [tmp_path / f"obj_{i}.png" for i in range(2)] + for p in layers: + _make_png(p) + bundle = orch.run(composite_image=composite, object_layers=layers) + for key in ("sigma_threshold_map", "detail_retention_rate_map"): + assert key in bundle.perception.signals + for key in ("answer_obj_count", "scene_difficulty", "degree_map", "hop_map", "diameter"): + assert key in bundle.logical.signals + + def test_one_layer_total_score(self, orch: ValidatorOrchestrator, composite: Path, tmp_path: Path) -> None: + """ + 1개 레이어: obj_1 은 physical/logical 기본값(0) 사용. + obj_1.logical = 0.0 (hop=0, degree_norm=0) + avg_perception = 0.35, avg_logical = 0.725/2 = 0.3625 + total = 0.35*0.45 + 0.3625*0.55 = 0.356875 + """ + layer = tmp_path / "obj_0.png" + _make_png(layer) + avg_perc = (_PERC_STD + _PERC_STD) / 2 # obj_1도 sigma/drr는 visual에서 + avg_logi = (_LOGI_STD + 0.0) / 2 + expected_tot = avg_perc * _W.total_perception + avg_logi * _W.total_logical + + bundle = orch.run(composite_image=composite, object_layers=[layer]) + assert bundle.final.total_score == pytest.approx(expected_tot, rel=1e-4) + + def test_one_layer_answer_obj_count(self, orch: ValidatorOrchestrator, composite: Path, tmp_path: Path) -> None: + layer = tmp_path / "obj_0.png" + _make_png(layer) + bundle = orch.run(composite_image=composite, object_layers=[layer]) + assert bundle.logical.signals["answer_obj_count"] == 1 + + def test_no_layers_uses_default_objs(self, orch: ValidatorOrchestrator, composite: Path) -> None: + bundle = orch.run(composite_image=composite, object_layers=[]) + assert bundle.final.total_score == pytest.approx(_TOT_STD, rel=1e-4) + assert bundle.final.pass_ is True + + def test_pass_field_is_bool(self, orch: ValidatorOrchestrator, composite: Path) -> None: + assert isinstance(orch.run(composite_image=composite, object_layers=[]).final.pass_, bool) + + def test_total_score_within_unit_range(self, orch: ValidatorOrchestrator, composite: Path) -> None: + bundle = orch.run(composite_image=composite, object_layers=[]) + assert 0.0 <= bundle.final.total_score <= 1.0 + + def test_custom_weights_change_score(self, composite: Path, tmp_path: Path) -> None: + """ScoringWeights 를 바꾸면 점수가 달라짐을 검증.""" + layers = [tmp_path / f"obj_{i}.png" for i in range(2)] + for p in layers: + _make_png(p) + default_orch = _make_orchestrator() + biased_orch = _make_orchestrator( + scoring_weights=ScoringWeights( + perception_sigma=0.90, perception_drr=0.10, + logical_hop=0.50, logical_degree=0.50, + total_perception=0.45, total_logical=0.55, + ) + ) + s1 = default_orch.run(composite_image=composite, object_layers=layers).final.total_score + s2 = biased_orch.run(composite_image=composite, object_layers=layers).final.total_score + assert s1 != pytest.approx(s2, rel=1e-3) + + +# =========================================================================== +# 2. "어려운" 시나리오 — max 점수 (= 1.0) +# =========================================================================== + +class TestE2EHardScenarioPass: + + @pytest.fixture() + def orch(self) -> ValidatorOrchestrator: + return _make_orchestrator( + logical_port=_HardLogicalExtraction(), + visual_port=_HardVisualVerification(), + ) + + @pytest.fixture() + def composite(self, tmp_path: Path) -> Path: + p = tmp_path / "composite.png" + _make_png(p) + return p + + def test_hard_scenario_max_total_score(self, orch: ValidatorOrchestrator, composite: Path, tmp_path: Path) -> None: + layers = [tmp_path / f"obj_{i}.png" for i in range(2)] + for p in layers: + _make_png(p) + assert orch.run(composite_image=composite, object_layers=layers).final.total_score == pytest.approx(_TOT_HARD, rel=1e-4) + + def test_hard_scenario_passes(self, orch: ValidatorOrchestrator, composite: Path, tmp_path: Path) -> None: + layers = [tmp_path / f"obj_{i}.png" for i in range(2)] + for p in layers: + _make_png(p) + bundle = orch.run(composite_image=composite, object_layers=layers) + assert bundle.final.pass_ is True and bundle.final.failure_reason == "" + + def test_hard_scenario_perception_and_logical(self, orch: ValidatorOrchestrator, composite: Path, tmp_path: Path) -> None: + layers = [tmp_path / f"obj_{i}.png" for i in range(2)] + for p in layers: + _make_png(p) + bundle = orch.run(composite_image=composite, object_layers=layers) + assert bundle.perception.score == pytest.approx(_PERC_HARD, rel=1e-4) + assert bundle.logical.score == pytest.approx(_LOGI_HARD, rel=1e-4) + + +# =========================================================================== +# 3. Phase 4 단독 E2E — resolve → score → difficulty 체인 +# =========================================================================== + +class TestE2EPhase4Chain: + + def test_resolve_and_score_standard_metrics(self) -> None: + metrics = { + "occlusion_ratio": 0.45, "sigma_threshold": 4.0, + "degree": 3, "degree_norm": 1.0, "z_depth_hop": 2, + "neighbor_count": 3, "hop": 2, "diameter": 4.0, + "detail_retention_rate": 0.55, + } + assert resolve_answer(metrics) is True + perc, logi, total = integrate_verification_v2(metrics) + assert perc == pytest.approx(_PERC_STD, rel=1e-4) + assert logi == pytest.approx(_LOGI_STD, rel=1e-4) + assert total == pytest.approx(_TOT_STD, rel=1e-4) + + def test_resolve_fails_below_two_conditions(self) -> None: + assert resolve_answer({"occlusion_ratio": 0.1, "sigma_threshold": 8.0, + "degree": 0, "z_depth_hop": 0, "neighbor_count": 0}) is False + + def test_difficulty_and_scene_difficulty_chain(self) -> None: + obj = {"occlusion_ratio": 0.45, "sigma_threshold": 4.0, "hop": 2, + "diameter": 4.0, "degree_norm": 1.0, "detail_retention_rate": 0.55} + d = compute_difficulty(obj) + assert d > 0.0 + assert compute_scene_difficulty([obj, obj]) == pytest.approx(d, rel=1e-4) + + def test_hard_metrics_full_chain(self) -> None: + hard = {"occlusion_ratio": 0.5, "sigma_threshold": 1.0, "degree": 4, + "degree_norm": 1.0, "z_depth_hop": 3, "neighbor_count": 4, + "hop": 4, "diameter": 4.0, "detail_retention_rate": 0.0} + assert resolve_answer(hard) is True + _, _, total = integrate_verification_v2(hard) + assert total == pytest.approx(_TOT_HARD, rel=1e-4) + assert total >= 0.35 + + def test_easy_metrics_full_chain(self) -> None: + easy = {"occlusion_ratio": 0.0, "sigma_threshold": 16.0, "degree": 0, + "degree_norm": 0.0, "z_depth_hop": 0, "neighbor_count": 0, + "hop": 0, "diameter": 1.0, "detail_retention_rate": 0.99} + assert resolve_answer(easy) is False + _, _, total = integrate_verification_v2(easy) + assert total < 0.10 + + def test_total_score_is_weighted_sum_of_sub_scores(self) -> None: + metrics = {"sigma_threshold": 2.0, "detail_retention_rate": 0.4, + "hop": 3, "diameter": 4.0, "degree_norm": 0.6} + w = ScoringWeights() + perc, logi, total = integrate_verification_v2(metrics) + assert total == pytest.approx(perc * w.total_perception + logi * w.total_logical, rel=1e-6) + + def test_all_scores_nonnegative_on_empty_metrics(self) -> None: + perc, logi, total = integrate_verification_v2({}) + assert perc >= 0.0 and logi >= 0.0 and total >= 0.0 + + def test_custom_weights_alter_score(self) -> None: + metrics = {"sigma_threshold": 4.0, "detail_retention_rate": 0.55, + "hop": 2, "diameter": 4.0, "degree_norm": 1.0} + _, _, total_default = integrate_verification_v2(metrics) + w_custom = ScoringWeights(perception_sigma=0.90, perception_drr=0.10, + logical_hop=0.90, logical_degree=0.10) + _, _, total_custom = integrate_verification_v2(metrics, weights=w_custom) + assert total_default != pytest.approx(total_custom, rel=1e-3) + + +# =========================================================================== +# 4. 실제 모델 어댑터 스텁 (skip) +# =========================================================================== + +@pytest.mark.skip(reason="MobileSAM: timm 패키지 미설치") +def test_phase1_real_mobilesam_loads() -> None: + from discoverex.adapters.outbound.models.hf_mobilesam import HFMobileSAMExtraction # noqa: F401 + raise AssertionError("이 테스트는 skip 되어야 함") + +@pytest.mark.skip(reason="Moondream2: transformers 미설치") +def test_phase2_real_moondream2_loads() -> None: + from discoverex.adapters.outbound.models.hf_moondream2 import HFMoondream2Extraction # noqa: F401 + raise AssertionError("이 테스트는 skip 되어야 함") + +@pytest.mark.skip(reason="YOLO+CLIP: transformers 미설치") +def test_phase3_real_yolo_clip_loads() -> None: + from discoverex.adapters.outbound.models.hf_yolo_clip import HFYoloCLIPVerification # noqa: F401 + raise AssertionError("이 테스트는 skip 되어야 함") diff --git a/tests/test_validator_pipeline_smoke.py b/tests/test_validator_pipeline_smoke.py new file mode 100644 index 0000000..25eeeaf --- /dev/null +++ b/tests/test_validator_pipeline_smoke.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from discoverex.adapters.outbound.models.dummy import ( + DummyLogicalExtraction, + DummyPhysicalExtraction, + DummyVisualVerification, +) +from discoverex.application.use_cases.validator import ValidatorOrchestrator +from discoverex.domain.verification import VerificationBundle +from discoverex.models.types import ModelHandle + + +def _make_handle(name: str) -> ModelHandle: + return ModelHandle(name=name, version="dummy", runtime="dummy") + + +@pytest.fixture() +def orchestrator() -> ValidatorOrchestrator: + return ValidatorOrchestrator( + physical_port=DummyPhysicalExtraction(), + logical_port=DummyLogicalExtraction(), + visual_port=DummyVisualVerification(), + physical_handle=_make_handle("physical"), + logical_handle=_make_handle("logical"), + visual_handle=_make_handle("visual"), + pass_threshold=0.35, + ) + + +class TestValidatorOrchestratorStructure: + def test_run_returns_verification_bundle( + self, orchestrator: ValidatorOrchestrator, tmp_path: Path + ) -> None: + composite = tmp_path / "composite.png" + composite.write_bytes(b"fake-image") + layer = tmp_path / "obj_0.png" + layer.write_bytes(b"fake-layer") + + bundle = orchestrator.run(composite_image=composite, object_layers=[layer]) + + assert isinstance(bundle, VerificationBundle) + + def test_total_score_in_range( + self, orchestrator: ValidatorOrchestrator, tmp_path: Path + ) -> None: + composite = tmp_path / "composite.png" + composite.write_bytes(b"fake-image") + layers = [tmp_path / f"obj_{i}.png" for i in range(2)] + for p in layers: + p.write_bytes(b"fake-layer") + + bundle = orchestrator.run(composite_image=composite, object_layers=layers) + + assert 0.0 <= bundle.final.total_score <= 1.0 # 정규화 수식 적용으로 max = 1.0 + + def test_pass_field_is_bool( + self, orchestrator: ValidatorOrchestrator, tmp_path: Path + ) -> None: + composite = tmp_path / "composite.png" + composite.write_bytes(b"fake-image") + + bundle = orchestrator.run(composite_image=composite, object_layers=[]) + + assert isinstance(bundle.final.pass_, bool) + + def test_failure_reason_empty_when_pass( + self, orchestrator: ValidatorOrchestrator, tmp_path: Path + ) -> None: + """If the bundle passes, failure_reason must be an empty string.""" + composite = tmp_path / "composite.png" + composite.write_bytes(b"fake-image") + layers = [tmp_path / f"obj_{i}.png" for i in range(2)] + for p in layers: + p.write_bytes(b"fake-layer") + + bundle = orchestrator.run(composite_image=composite, object_layers=layers) + + if bundle.final.pass_: + assert bundle.final.failure_reason == "" + else: + assert bundle.final.failure_reason != "" + + def test_signals_contain_expected_keys( + self, orchestrator: ValidatorOrchestrator, tmp_path: Path + ) -> None: + composite = tmp_path / "composite.png" + composite.write_bytes(b"fake-image") + + bundle = orchestrator.run(composite_image=composite, object_layers=[]) + + assert "answer_obj_count" in bundle.logical.signals + assert "sigma_threshold_map" in bundle.perception.signals + assert "detail_retention_rate_map" in bundle.perception.signals diff --git a/tests/test_validator_scoring.py b/tests/test_validator_scoring.py new file mode 100644 index 0000000..1a489c1 --- /dev/null +++ b/tests/test_validator_scoring.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import pytest + +from discoverex.domain.services.verification import ( + ScoringWeights, + compute_difficulty, + compute_scene_difficulty, + integrate_verification_v2, + resolve_answer, +) + + +# --------------------------------------------------------------------------- +# resolve_answer +# --------------------------------------------------------------------------- + +class TestResolveAnswer: + def test_two_conditions_returns_true(self) -> None: + metrics = { + "occlusion_ratio": 0.5, # > 0.3 ✓ + "sigma_threshold": 2.0, # <= 4 ✓ + "degree": 0, + "z_depth_hop": 0, + "neighbor_count": 0, + } + assert resolve_answer(metrics) is True + + def test_one_condition_returns_false(self) -> None: + metrics = { + "occlusion_ratio": 0.5, # > 0.3 ✓ + "sigma_threshold": 16.0, # not <= 4 + "degree": 0, + "z_depth_hop": 0, + "neighbor_count": 0, + } + assert resolve_answer(metrics) is False + + def test_all_conditions_met(self) -> None: + metrics = { + "occlusion_ratio": 0.6, + "sigma_threshold": 2.0, + "degree": 5, + "z_depth_hop": 3, + "neighbor_count": 4, + } + assert resolve_answer(metrics) is True + + def test_missing_keys_use_defaults(self) -> None: + # Empty dict: all conditions False → should return False + assert resolve_answer({}) is False + + def test_boundary_occlusion_exactly_03(self) -> None: + # occlusion_ratio == 0.3 is NOT > 0.3 + metrics = {"occlusion_ratio": 0.3, "sigma_threshold": 2.0} + assert resolve_answer(metrics) is False + + def test_boundary_sigma_exactly_4(self) -> None: + # sigma_threshold == 4 IS <= 4 + metrics = {"occlusion_ratio": 0.5, "sigma_threshold": 4.0} + assert resolve_answer(metrics) is True + + +# --------------------------------------------------------------------------- +# compute_difficulty +# --------------------------------------------------------------------------- + +class TestComputeDifficulty: + def test_zero_metrics_returns_nonnegative(self) -> None: + metrics = { + "occlusion_ratio": 0.0, + "sigma_threshold": 1.0, + "hop": 0, + "diameter": 1.0, + "degree_norm": 0.0, + "detail_retention_rate": 1.0, + } + score = compute_difficulty(metrics) + assert score >= 0.0 + + def test_high_difficulty_metrics_exceeds_low(self) -> None: + easy = { + "occlusion_ratio": 0.0, + "sigma_threshold": 16.0, + "hop": 0, + "diameter": 4.0, + "degree_norm": 0.0, + "detail_retention_rate": 1.0, + } + hard = { + "occlusion_ratio": 0.9, + "sigma_threshold": 1.0, + "hop": 3, + "diameter": 4.0, + "degree_norm": 0.9, + "detail_retention_rate": 0.1, + } + assert compute_difficulty(hard) > compute_difficulty(easy) + + def test_missing_keys_use_defaults(self) -> None: + score = compute_difficulty({}) + assert score >= 0.0 + + def test_sigma_zero_guarded(self) -> None: + # sigma_threshold=0 must not raise ZeroDivisionError + metrics = {"sigma_threshold": 0.0} + score = compute_difficulty(metrics) + assert score >= 0.0 + + def test_diameter_zero_guarded(self) -> None: + metrics = {"hop": 2, "diameter": 0.0} + score = compute_difficulty(metrics) + assert score >= 0.0 + + +# --------------------------------------------------------------------------- +# compute_scene_difficulty +# --------------------------------------------------------------------------- + +class TestComputeSceneDifficulty: + def test_empty_list_returns_zero(self) -> None: + assert compute_scene_difficulty([]) == 0.0 + + def test_single_object_equals_compute_difficulty(self) -> None: + obj = {"occlusion_ratio": 0.5, "sigma_threshold": 4.0, + "hop": 2, "diameter": 4.0, "degree_norm": 0.3, + "detail_retention_rate": 0.6} + assert compute_scene_difficulty([obj]) == pytest.approx(compute_difficulty(obj)) + + def test_multiple_objects_is_average(self) -> None: + objs = [ + {"occlusion_ratio": 0.4, "sigma_threshold": 4.0, "hop": 1, + "diameter": 3.0, "degree_norm": 0.2, "detail_retention_rate": 0.7}, + {"occlusion_ratio": 0.8, "sigma_threshold": 2.0, "hop": 3, + "diameter": 3.0, "degree_norm": 0.7, "detail_retention_rate": 0.3}, + ] + expected = sum(compute_difficulty(o) for o in objs) / 2 + assert compute_scene_difficulty(objs) == pytest.approx(expected) + + +# --------------------------------------------------------------------------- +# integrate_verification_v2 +# --------------------------------------------------------------------------- + +class TestIntegrateVerificationV2: + def test_returns_three_floats(self) -> None: + result = integrate_verification_v2({}) + assert len(result) == 3 + assert all(isinstance(v, float) for v in result) + + def test_high_difficulty_passes_threshold(self) -> None: + # 정규화 수식 기준: + # perception = (0.5*(1/1) + 0.5*(1-0.0)) / 1.0 = 1.0 + # logical = (0.55*(4/4) + 0.45*(1.0)²) / 1.0 = 1.0 + # total = 1.0*0.45 + 1.0*0.55 = 1.0 >= 0.35 + hard = { + "sigma_threshold": 1.0, + "detail_retention_rate": 0.0, + "hop": 4, + "diameter": 4.0, + "degree_norm": 1.0, + } + perception, logical, total = integrate_verification_v2(hard, pass_threshold=0.35) + assert total >= 0.35 + + def test_easy_scene_below_threshold(self) -> None: + easy = { + "sigma_threshold": 16.0, + "detail_retention_rate": 0.99, + "hop": 0, + "diameter": 1.0, + "degree_norm": 0.0, + } + _, _, total = integrate_verification_v2(easy, pass_threshold=0.35) + assert total < 0.35 + + def test_scores_are_nonnegative(self) -> None: + for _ in range(5): + p, l, t = integrate_verification_v2({}) + assert p >= 0.0 + assert l >= 0.0 + assert t >= 0.0 + + def test_weighted_sum_formula(self) -> None: + metrics = { + "sigma_threshold": 4.0, + "detail_retention_rate": 0.6, + "hop": 2, + "diameter": 4.0, + "degree_norm": 0.5, + } + w = ScoringWeights() + p, l, total = integrate_verification_v2(metrics) + expected_total = p * w.total_perception + l * w.total_logical + assert total == pytest.approx(expected_total, rel=1e-6) diff --git a/uv.lock b/uv.lock index 99e3a15..4c69c2b 100644 --- a/uv.lock +++ b/uv.lock @@ -2,9 +2,12 @@ version = 1 revision = 3 requires-python = ">=3.11" resolution-markers = [ - "python_full_version >= '3.13'", - "python_full_version == '3.12.*'", - "python_full_version < '3.12'", + "python_full_version >= '3.13' and sys_platform == 'win32'", + "python_full_version >= '3.13' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'win32'", + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform != 'win32'", ] [[package]] @@ -76,6 +79,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, ] +[[package]] +name = "bitsandbytes" +version = "0.49.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "packaging" }, + { name = "torch" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/7d/f1fe0992334b18cd8494f89aeec1dcc674635584fcd9f115784fea3a1d05/bitsandbytes-0.49.2-py3-none-macosx_14_0_arm64.whl", hash = "sha256:87be5975edeac5396d699ecbc39dfc47cf2c026daaf2d5852a94368611a6823f", size = 131940, upload-time = "2026-02-16T21:26:04.572Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/acff7af06c818664aa87ff73e17a52c7788ad746b72aea09d3cb8e424348/bitsandbytes-0.49.2-py3-none-manylinux_2_24_aarch64.whl", hash = "sha256:2fc0830c5f7169be36e60e11f2be067c8f812dfcb829801a8703735842450750", size = 31442815, upload-time = "2026-02-16T21:26:06.783Z" }, + { url = "https://files.pythonhosted.org/packages/19/57/3443d6f183436fbdaf5000aac332c4d5ddb056665d459244a5608e98ae92/bitsandbytes-0.49.2-py3-none-manylinux_2_24_x86_64.whl", hash = "sha256:54b771f06e1a3c73af5c7f16ccf0fc23a846052813d4b008d10cb6e017dd1c8c", size = 60651714, upload-time = "2026-02-16T21:26:11.579Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d4/501655842ad6771fb077f576d78cbedb5445d15b1c3c91343ed58ca46f0e/bitsandbytes-0.49.2-py3-none-win_amd64.whl", hash = "sha256:2e0ddd09cd778155388023cbe81f00afbb7c000c214caef3ce83386e7144df7d", size = 55372289, upload-time = "2026-02-16T21:26:16.267Z" }, +] + [[package]] name = "blinker" version = "1.9.0" @@ -450,7 +469,7 @@ name = "cuda-bindings" version = "12.9.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-pathfinder" }, + { name = "cuda-pathfinder", marker = "sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/45/e7/b47792cc2d01c7e1d37c32402182524774dadd2d26339bd224e0e913832e/cuda_bindings-12.9.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c912a3d9e6b6651853eed8eed96d6800d69c08e94052c292fec3f282c5a817c9", size = 12210593, upload-time = "2025-10-21T14:51:36.574Z" }, @@ -553,18 +572,31 @@ storage = [ tracking = [ { name = "mlflow" }, ] +validator = [ + { name = "bitsandbytes" }, + { name = "mobile-sam" }, + { name = "networkx" }, + { name = "numpy" }, + { name = "pillow" }, + { name = "ultralytics" }, +] [package.metadata] requires-dist = [ { name = "accelerate", marker = "extra == 'ml-gpu'", specifier = ">=1.12.0" }, + { name = "bitsandbytes", marker = "extra == 'validator'", specifier = ">=0.49.2" }, { name = "boto3", marker = "extra == 'storage'", specifier = ">=1.42.59" }, { name = "diffusers", marker = "extra == 'ml-cpu'", specifier = ">=0.35.2" }, { name = "diffusers", marker = "extra == 'ml-gpu'", specifier = ">=0.35.2" }, { name = "hydra-core", specifier = ">=1.3.2" }, { name = "mlflow", marker = "extra == 'tracking'", specifier = ">=3.10.0" }, + { name = "mobile-sam", marker = "extra == 'validator'", git = "https://github.com/ChaoningZhang/MobileSAM.git" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.19.1" }, + { name = "networkx", marker = "extra == 'validator'", specifier = ">=3.6.1" }, + { name = "numpy", marker = "extra == 'validator'", specifier = ">=2.4.2" }, { name = "pillow", marker = "extra == 'ml-cpu'", specifier = ">=11.3.0" }, { name = "pillow", marker = "extra == 'ml-gpu'", specifier = ">=11.3.0" }, + { name = "pillow", marker = "extra == 'validator'", specifier = ">=12.1.1" }, { name = "psycopg", extras = ["binary"], marker = "extra == 'storage'", specifier = ">=3.3.3" }, { name = "pydantic", specifier = ">=2.12.5" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2" }, @@ -579,8 +611,9 @@ requires-dist = [ { name = "transformers", marker = "extra == 'ml-gpu'", specifier = ">=5.2.0" }, { name = "typer", specifier = ">=0.24.1" }, { name = "types-pyyaml", marker = "extra == 'dev'", specifier = ">=6.0.12.20250915" }, + { name = "ultralytics", marker = "extra == 'validator'", specifier = ">=8.4.19" }, ] -provides-extras = ["tracking", "storage", "ml-cpu", "ml-gpu", "dev"] +provides-extras = ["tracking", "storage", "ml-cpu", "ml-gpu", "validator", "dev"] [[package]] name = "docker" @@ -792,7 +825,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f3/47/16400cb42d18d7a6bb46f0626852c1718612e35dcb0dffa16bbaffdf5dd2/greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86", size = 278890, upload-time = "2026-02-20T20:19:39.263Z" }, { url = "https://files.pythonhosted.org/packages/a3/90/42762b77a5b6aa96cd8c0e80612663d39211e8ae8a6cd47c7f1249a66262/greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f", size = 581120, upload-time = "2026-02-20T20:47:30.161Z" }, { url = "https://files.pythonhosted.org/packages/bf/6f/f3d64f4fa0a9c7b5c5b3c810ff1df614540d5aa7d519261b53fba55d4df9/greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55", size = 594363, upload-time = "2026-02-20T20:55:56.965Z" }, - { url = "https://files.pythonhosted.org/packages/9c/8b/1430a04657735a3f23116c2e0d5eb10220928846e4537a938a41b350bed6/greenlet-3.3.2-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4375a58e49522698d3e70cc0b801c19433021b5c37686f7ce9c65b0d5c8677d2", size = 605046, upload-time = "2026-02-20T21:02:45.234Z" }, { url = "https://files.pythonhosted.org/packages/72/83/3e06a52aca8128bdd4dcd67e932b809e76a96ab8c232a8b025b2850264c5/greenlet-3.3.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358", size = 594156, upload-time = "2026-02-20T20:20:59.955Z" }, { url = "https://files.pythonhosted.org/packages/70/79/0de5e62b873e08fe3cef7dbe84e5c4bc0e8ed0c7ff131bccb8405cd107c8/greenlet-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99", size = 1554649, upload-time = "2026-02-20T20:49:32.293Z" }, { url = "https://files.pythonhosted.org/packages/5a/00/32d30dee8389dc36d42170a9c66217757289e2afb0de59a3565260f38373/greenlet-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be", size = 1619472, upload-time = "2026-02-20T20:21:07.966Z" }, @@ -801,7 +833,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, - { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" }, { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, @@ -810,7 +841,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, - { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, @@ -819,7 +849,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, - { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, @@ -828,7 +857,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, - { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, @@ -840,7 +868,7 @@ name = "gunicorn" version = "25.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "packaging" }, + { name = "packaging", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/13/ef67f59f6a7896fdc2c1d62b5665c5219d6b0a9a1784938eb9a28e55e128/gunicorn-25.1.0.tar.gz", hash = "sha256:1426611d959fa77e7de89f8c0f32eed6aa03ee735f98c01efba3e281b1c47616", size = 594377, upload-time = "2026-02-13T11:09:58.989Z" } wheels = [ @@ -1441,6 +1469,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/3e/778b559cfa58cd25ccf2020ba6f758c1032d66e942d9dee65b17ab495e97/mlflow_tracing-3.10.0-py3-none-any.whl", hash = "sha256:eb172a48e8f8078b0387e3e044864bf2c83124f7e773b223d37f6da79227a714", size = 1493631, upload-time = "2026-02-20T12:54:41.372Z" }, ] +[[package]] +name = "mobile-sam" +version = "1.0" +source = { git = "https://github.com/ChaoningZhang/MobileSAM.git#b01a9ccef3b9e10b099b544efe004d0871802c3b" } + [[package]] name = "mpmath" version = "1.3.0" @@ -1623,7 +1656,7 @@ name = "nvidia-cudnn-cu12" version = "9.10.2.21" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas-cu12" }, + { name = "nvidia-cublas-cu12", marker = "sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/ba/51/e123d997aa098c61d029f76663dedbfb9bc8dcf8c60cbd6adbe42f76d049/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:949452be657fa16687d0930933f032835951ef0892b37d2d53824d1a84dc97a8", size = 706758467, upload-time = "2025-06-06T21:54:08.597Z" }, @@ -1634,7 +1667,7 @@ name = "nvidia-cufft-cu12" version = "11.3.3.83" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink-cu12" }, + { name = "nvidia-nvjitlink-cu12", marker = "sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/1f/13/ee4e00f30e676b66ae65b4f08cb5bcbb8392c03f54f2d5413ea99a5d1c80/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d2dd21ec0b88cf61b62e6b43564355e5222e4a3fb394cac0db101f2dd0d4f74", size = 193118695, upload-time = "2025-03-07T01:45:27.821Z" }, @@ -1661,9 +1694,9 @@ name = "nvidia-cusolver-cu12" version = "11.7.3.90" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas-cu12" }, - { name = "nvidia-cusparse-cu12" }, - { name = "nvidia-nvjitlink-cu12" }, + { name = "nvidia-cublas-cu12", marker = "sys_platform != 'win32'" }, + { name = "nvidia-cusparse-cu12", marker = "sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink-cu12", marker = "sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/85/48/9a13d2975803e8cf2777d5ed57b87a0b6ca2cc795f9a4f59796a910bfb80/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:4376c11ad263152bd50ea295c05370360776f8c3427b30991df774f9fb26c450", size = 267506905, upload-time = "2025-03-07T01:47:16.273Z" }, @@ -1674,7 +1707,7 @@ name = "nvidia-cusparse-cu12" version = "12.5.8.93" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink-cu12" }, + { name = "nvidia-nvjitlink-cu12", marker = "sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/c2/f5/e1854cb2f2bcd4280c44736c93550cc300ff4b8c95ebe370d0aa7d2b473d/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ec05d76bbbd8b61b06a80e1eaf8cf4959c3d4ce8e711b65ebd0443bb0ebb13b", size = 288216466, upload-time = "2025-03-07T01:48:13.779Z" }, @@ -1733,6 +1766,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e3/94/1843518e420fa3ed6919835845df698c7e27e183cb997394e4a670973a65/omegaconf-2.3.0-py3-none-any.whl", hash = "sha256:7b4df175cdb08ba400f45cae3bdcae7ba8365db4d165fc65fd04b050ab63b46b", size = 79500, upload-time = "2022-12-08T20:59:19.686Z" }, ] +[[package]] +name = "opencv-python" +version = "4.13.0.92" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/6f/5a28fef4c4a382be06afe3938c64cc168223016fa520c5abaf37e8862aa5/opencv_python-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:caf60c071ec391ba51ed00a4a920f996d0b64e3e46068aac1f646b5de0326a19", size = 46247052, upload-time = "2026-02-05T07:01:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/08/ac/6c98c44c650b8114a0fb901691351cfb3956d502e8e9b5cd27f4ee7fbf2f/opencv_python-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:5868a8c028a0b37561579bfb8ac1875babdc69546d236249fff296a8c010ccf9", size = 32568781, upload-time = "2026-02-05T07:01:41.379Z" }, + { url = "https://files.pythonhosted.org/packages/3e/51/82fed528b45173bf629fa44effb76dff8bc9f4eeaee759038362dfa60237/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bc2596e68f972ca452d80f444bc404e08807d021fbba40df26b61b18e01838a", size = 47685527, upload-time = "2026-02-05T06:59:11.24Z" }, + { url = "https://files.pythonhosted.org/packages/db/07/90b34a8e2cf9c50fe8ed25cac9011cde0676b4d9d9c973751ac7616223a2/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:402033cddf9d294693094de5ef532339f14ce821da3ad7df7c9f6e8316da32cf", size = 70460872, upload-time = "2026-02-05T06:59:19.162Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/7a9cc719b3eaf4377b9c2e3edeb7ed3a81de41f96421510c0a169ca3cfd4/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:bccaabf9eb7f897ca61880ce2869dcd9b25b72129c28478e7f2a5e8dee945616", size = 46708208, upload-time = "2026-02-05T06:59:15.419Z" }, + { url = "https://files.pythonhosted.org/packages/fd/55/b3b49a1b97aabcfbbd6c7326df9cb0b6fa0c0aefa8e89d500939e04aa229/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:620d602b8f7d8b8dab5f4b99c6eb353e78d3fb8b0f53db1bd258bb1aa001c1d5", size = 72927042, upload-time = "2026-02-05T06:59:23.389Z" }, + { url = "https://files.pythonhosted.org/packages/fb/17/de5458312bcb07ddf434d7bfcb24bb52c59635ad58c6e7c751b48949b009/opencv_python-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:372fe164a3148ac1ca51e5f3ad0541a4a276452273f503441d718fab9c5e5f59", size = 30932638, upload-time = "2026-02-05T07:02:14.98Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" }, +] + [[package]] name = "opentelemetry-api" version = "1.39.1" @@ -1953,6 +2004,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "polars" +version = "1.38.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "polars-runtime-32" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/5e/208a24471a433bcd0e9a6889ac49025fd4daad2815c8220c5bd2576e5f1b/polars-1.38.1.tar.gz", hash = "sha256:803a2be5344ef880ad625addfb8f641995cfd777413b08a10de0897345778239", size = 717667, upload-time = "2026-02-06T18:13:23.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/49/737c1a6273c585719858261753da0b688454d1b634438ccba8a9c4eb5aab/polars-1.38.1-py3-none-any.whl", hash = "sha256:a29479c48fed4984d88b656486d221f638cba45d3e961631a50ee5fdde38cb2c", size = 810368, upload-time = "2026-02-06T18:11:55.819Z" }, +] + +[[package]] +name = "polars-runtime-32" +version = "1.38.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/07/4b/04d6b3fb7cf336fbe12fbc4b43f36d1783e11bb0f2b1e3980ec44878df06/polars_runtime_32-1.38.1.tar.gz", hash = "sha256:04f20ed1f5c58771f34296a27029dc755a9e4b1390caeaef8f317e06fdfce2ec", size = 2812631, upload-time = "2026-02-06T18:13:25.206Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/a2/a00defbddadd8cf1042f52380dcba6b6592b03bac8e3b34c436b62d12d3b/polars_runtime_32-1.38.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:18154e96044724a0ac38ce155cf63aa03c02dd70500efbbf1a61b08cadd269ef", size = 44108001, upload-time = "2026-02-06T18:11:58.127Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/599ff3709e6a303024efd7edfd08cf8de55c6ac39527d8f41cbc4399385f/polars_runtime_32-1.38.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c49acac34cc4049ed188f1eb67d6ff3971a39b4af7f7b734b367119970f313ac", size = 40230140, upload-time = "2026-02-06T18:12:01.181Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8c/3ac18d6f89dc05fe2c7c0ee1dc5b81f77a5c85ad59898232c2500fe2ebbf/polars_runtime_32-1.38.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fef2ef2626a954e010e006cc8e4de467ecf32d08008f130cea1c78911f545323", size = 41994039, upload-time = "2026-02-06T18:12:04.332Z" }, + { url = "https://files.pythonhosted.org/packages/f2/5a/61d60ec5cc0ab37cbd5a699edb2f9af2875b7fdfdfb2a4608ca3cc5f0448/polars_runtime_32-1.38.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e8a5f7a8125e2d50e2e060296551c929aec09be23a9edcb2b12ca923f555a5ba", size = 45755804, upload-time = "2026-02-06T18:12:07.846Z" }, + { url = "https://files.pythonhosted.org/packages/91/54/02cd4074c98c361ccd3fec3bcb0bd68dbc639c0550c42a4436b0ff0f3ccf/polars_runtime_32-1.38.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:10d19cd9863e129273b18b7fcaab625b5c8143c2d22b3e549067b78efa32e4fa", size = 42159605, upload-time = "2026-02-06T18:12:10.919Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f3/b2a5e720cc56eaa38b4518e63aa577b4bbd60e8b05a00fe43ca051be5879/polars_runtime_32-1.38.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61e8d73c614b46a00d2f853625a7569a2e4a0999333e876354ac81d1bf1bb5e2", size = 45336615, upload-time = "2026-02-06T18:12:14.074Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8d/ee2e4b7de948090cfb3df37d401c521233daf97bfc54ddec5d61d1d31618/polars_runtime_32-1.38.1-cp310-abi3-win_amd64.whl", hash = "sha256:08c2b3b93509c1141ac97891294ff5c5b0c548a373f583eaaea873a4bf506437", size = 45680732, upload-time = "2026-02-06T18:12:19.097Z" }, + { url = "https://files.pythonhosted.org/packages/bf/18/72c216f4ab0c82b907009668f79183ae029116ff0dd245d56ef58aac48e7/polars_runtime_32-1.38.1-cp310-abi3-win_arm64.whl", hash = "sha256:6d07d0cc832bfe4fb54b6e04218c2c27afcfa6b9498f9f6bbf262a00d58cc7c4", size = 41639413, upload-time = "2026-02-06T18:12:22.044Z" }, +] + [[package]] name = "prettytable" version = "3.17.0" @@ -2965,6 +3044,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/66/4d/35352043ee0eaffdeff154fad67cd4a31dbed7ff8e3be1cc4549717d6d51/torch-2.10.0-cp314-cp314t-win_amd64.whl", hash = "sha256:71283a373f0ee2c89e0f0d5f446039bdabe8dbc3c9ccf35f0f784908b0acd185", size = 113995816, upload-time = "2026-01-21T16:22:05.312Z" }, ] +[[package]] +name = "torchvision" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, + { name = "torch" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/be/c704bceaf11c4f6b19d64337a34a877fcdfe3bd68160a8c9ae9bea4a35a3/torchvision-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db74a551946b75d19f9996c419a799ffdf6a223ecf17c656f90da011f1d75b20", size = 1874923, upload-time = "2026-01-21T16:27:46.574Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e9/f143cd71232430de1f547ceab840f68c55e127d72558b1061a71d0b193cd/torchvision-0.25.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f49964f96644dbac2506dffe1a0a7ec0f2bf8cf7a588c3319fed26e6329ffdf3", size = 2344808, upload-time = "2026-01-21T16:27:43.191Z" }, + { url = "https://files.pythonhosted.org/packages/43/ae/ad5d6165797de234c9658752acb4fce65b78a6a18d82efdf8367c940d8da/torchvision-0.25.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:153c0d2cbc34b7cf2da19d73450f24ba36d2b75ec9211b9962b5022fb9e4ecee", size = 8070752, upload-time = "2026-01-21T16:27:33.748Z" }, + { url = "https://files.pythonhosted.org/packages/23/19/55b28aecdc7f38df57b8eb55eb0b14a62b470ed8efeb22cdc74224df1d6a/torchvision-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:ea580ffd6094cc01914ad32f8c8118174f18974629af905cea08cb6d5d48c7b7", size = 4038722, upload-time = "2026-01-21T16:27:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/56/3a/6ea0d73f49a9bef38a1b3a92e8dd455cea58470985d25635beab93841748/torchvision-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c2abe430c90b1d5e552680037d68da4eb80a5852ebb1c811b2b89d299b10573b", size = 1874920, upload-time = "2026-01-21T16:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/51/f8/c0e1ef27c66e15406fece94930e7d6feee4cb6374bbc02d945a630d6426e/torchvision-0.25.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:b75deafa2dfea3e2c2a525559b04783515e3463f6e830cb71de0fb7ea36fe233", size = 2344556, upload-time = "2026-01-21T16:27:40.125Z" }, + { url = "https://files.pythonhosted.org/packages/68/2f/f24b039169db474e8688f649377de082a965fbf85daf4e46c44412f1d15a/torchvision-0.25.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:f25aa9e380865b11ea6e9d99d84df86b9cc959f1a007cd966fc6f1ab2ed0e248", size = 8072351, upload-time = "2026-01-21T16:27:21.074Z" }, + { url = "https://files.pythonhosted.org/packages/ad/16/8f650c2e288977cf0f8f85184b90ee56ed170a4919347fc74ee99286ed6f/torchvision-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:f9c55ae8d673ab493325d1267cbd285bb94d56f99626c00ac4644de32a59ede3", size = 4303059, upload-time = "2026-01-21T16:27:11.08Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5b/1562a04a6a5a4cf8cf40016a0cdeda91ede75d6962cff7f809a85ae966a5/torchvision-0.25.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:24e11199e4d84ba9c5ee7825ebdf1cd37ce8deec225117f10243cae984ced3ec", size = 1874918, upload-time = "2026-01-21T16:27:39.02Z" }, + { url = "https://files.pythonhosted.org/packages/36/b1/3d6c42f62c272ce34fcce609bb8939bdf873dab5f1b798fd4e880255f129/torchvision-0.25.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:5f271136d2d2c0b7a24c5671795c6e4fd8da4e0ea98aeb1041f62bc04c4370ef", size = 2309106, upload-time = "2026-01-21T16:27:30.624Z" }, + { url = "https://files.pythonhosted.org/packages/c7/60/59bb9c8b67cce356daeed4cb96a717caa4f69c9822f72e223a0eae7a9bd9/torchvision-0.25.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:855c0dc6d37f462482da7531c6788518baedca1e0847f3df42a911713acdfe52", size = 8071522, upload-time = "2026-01-21T16:27:29.392Z" }, + { url = "https://files.pythonhosted.org/packages/32/a5/9a9b1de0720f884ea50dbf9acb22cbe5312e51d7b8c4ac6ba9b51efd9bba/torchvision-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:cef0196be31be421f6f462d1e9da1101be7332d91984caa6f8022e6c78a5877f", size = 4321911, upload-time = "2026-01-21T16:27:35.195Z" }, + { url = "https://files.pythonhosted.org/packages/52/99/dca81ed21ebaeff2b67cc9f815a20fdaa418b69f5f9ea4c6ed71721470db/torchvision-0.25.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a8f8061284395ce31bcd460f2169013382ccf411148ceb2ee38e718e9860f5a7", size = 1896209, upload-time = "2026-01-21T16:27:32.159Z" }, + { url = "https://files.pythonhosted.org/packages/28/cc/2103149761fdb4eaed58a53e8437b2d716d48f05174fab1d9fcf1e2a2244/torchvision-0.25.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:146d02c9876858420adf41f3189fe90e3d6a409cbfa65454c09f25fb33bf7266", size = 2310735, upload-time = "2026-01-21T16:27:22.327Z" }, + { url = "https://files.pythonhosted.org/packages/76/ad/f4c985ad52ddd3b22711c588501be1b330adaeaf6850317f66751711b78c/torchvision-0.25.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:c4d395cb2c4a2712f6eb93a34476cdf7aae74bb6ea2ea1917f858e96344b00aa", size = 8089557, upload-time = "2026-01-21T16:27:27.666Z" }, + { url = "https://files.pythonhosted.org/packages/63/cc/0ea68b5802e5e3c31f44b307e74947bad5a38cc655231d845534ed50ddb8/torchvision-0.25.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5e6b449e9fa7d642142c0e27c41e5a43b508d57ed8e79b7c0a0c28652da8678c", size = 4344260, upload-time = "2026-01-21T16:27:17.018Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1f/fa839532660e2602b7e704d65010787c5bb296258b44fa8b9c1cd6175e7d/torchvision-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:620a236288d594dcec7634c754484542dc0a5c1b0e0b83a34bda5e91e9b7c3a1", size = 1896193, upload-time = "2026-01-21T16:27:24.785Z" }, + { url = "https://files.pythonhosted.org/packages/80/ed/d51889da7ceaf5ff7a0574fb28f9b6b223df19667265395891f81b364ab3/torchvision-0.25.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0b5e7f50002a8145a98c5694a018e738c50e2972608310c7e88e1bd4c058f6ce", size = 2309331, upload-time = "2026-01-21T16:27:19.97Z" }, + { url = "https://files.pythonhosted.org/packages/90/a5/f93fcffaddd8f12f9e812256830ec9c9ca65abbf1bc369379f9c364d1ff4/torchvision-0.25.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:632db02300e83793812eee4f61ae6a2686dab10b4cfd628b620dc47747aa9d03", size = 8088713, upload-time = "2026-01-21T16:27:15.281Z" }, + { url = "https://files.pythonhosted.org/packages/1f/eb/d0096eed5690d962853213f2ee00d91478dfcb586b62dbbb449fb8abc3a6/torchvision-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:d1abd5ed030c708f5dbf4812ad5f6fbe9384b63c40d6bd79f8df41a4a759a917", size = 4325058, upload-time = "2026-01-21T16:27:26.165Z" }, + { url = "https://files.pythonhosted.org/packages/97/36/96374a4c7ab50dea9787ce987815614ccfe988a42e10ac1a2e3e5b60319a/torchvision-0.25.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad9a8a5877782944d99186e4502a614770fe906626d76e9cd32446a0ac3075f2", size = 1896207, upload-time = "2026-01-21T16:27:23.383Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e2/7abb10a867db79b226b41da419b63b69c0bd5b82438c4a4ed50e084c552f/torchvision-0.25.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:40a122c3cf4d14b651f095e0f672b688dde78632783fc5cd3d4d5e4f6a828563", size = 2310741, upload-time = "2026-01-21T16:27:18.712Z" }, + { url = "https://files.pythonhosted.org/packages/08/e6/0927784e6ffc340b6676befde1c60260bd51641c9c574b9298d791a9cda4/torchvision-0.25.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:846890161b825b38aa85fc37fb3ba5eea74e7091ff28bab378287111483b6443", size = 8089772, upload-time = "2026-01-21T16:27:14.048Z" }, + { url = "https://files.pythonhosted.org/packages/b6/37/e7ca4ec820d434c0f23f824eb29f0676a0c3e7a118f1514f5b949c3356da/torchvision-0.25.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f07f01d27375ad89d72aa2b3f2180f07da95dd9d2e4c758e015c0acb2da72977", size = 4425879, upload-time = "2026-01-21T16:27:12.579Z" }, +] + [[package]] name = "tqdm" version = "4.67.3" @@ -3076,6 +3191,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, ] +[[package]] +name = "ultralytics" +version = "8.4.19" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "matplotlib" }, + { name = "numpy" }, + { name = "opencv-python" }, + { name = "pillow" }, + { name = "polars" }, + { name = "psutil" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "scipy" }, + { name = "torch" }, + { name = "torchvision" }, + { name = "ultralytics-thop" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/93/c1136865ed59be3e87d333c095e7ab91e0c428f42429a3f8560b1661c3db/ultralytics-8.4.19.tar.gz", hash = "sha256:e4bb5abf58e54fcb2c36fe37a6b12ab96b73de766692f24e53b69f1fa8987eb3", size = 1017018, upload-time = "2026-02-28T12:57:51.132Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/3f/d863ffc991e52f82e853472a1d8350ac1be3493fd887c2a8ecfe4fdf81b1/ultralytics-8.4.19-py3-none-any.whl", hash = "sha256:a21d90d4c6739158f959c02cec2590fa8a14f1348f1ef81d5a3e93ea32ae1d9b", size = 1191884, upload-time = "2026-02-28T12:57:47.389Z" }, +] + +[[package]] +name = "ultralytics-thop" +version = "2.0.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "torch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/63/21a32e1facfeee245dbdfb7b4669faf7a36ff7c00b50987932bdab126f4b/ultralytics_thop-2.0.18.tar.gz", hash = "sha256:21103bcd39cc9928477dc3d9374561749b66a1781b35f46256c8d8c4ac01d9cf", size = 34557, upload-time = "2025-10-29T16:58:13.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/c7/fb42228bb05473d248c110218ffb8b1ad2f76728ed8699856e5af21112ad/ultralytics_thop-2.0.18-py3-none-any.whl", hash = "sha256:2bb44851ad224b116c3995b02dd5e474a5ccf00acf237fe0edb9e1506ede04ec", size = 28941, upload-time = "2025-10-29T16:58:12.093Z" }, +] + [[package]] name = "urllib3" version = "2.6.3"