diff --git a/btkach-demo-drowsiness/README.md b/btkach-demo-drowsiness/README.md new file mode 100644 index 0000000..325414b --- /dev/null +++ b/btkach-demo-drowsiness/README.md @@ -0,0 +1,21 @@ +# Drowsiness Detection using Eye Aspect Ratio (EAR) + +This service detects potential driver drowsiness by monitoring eye closure over time using the Eye Aspect Ratio (EAR). + +## How EAR Works +- EAR quantifies how “open” an eye is from facial landmarks (e.g., MediaPipe Face Mesh). +- For each eye, two vertical distances (between eyelids) and one horizontal distance (across the eye) are measured. +- Formula: EAR = (vertical1 + vertical2) / (2 × horizontal). + - When the eye closes, vertical distances shrink → EAR drops. + - When the eye is open, EAR stays higher and more stable. + +## Algorithm Steps (as implemented in `src/my-service/main.py`) +1. Detect face and eye landmarks per frame (e.g., via MediaPipe Face Mesh). +2. Compute EAR for left and right eyes; average them for robustness. +3. Smooth the EAR with a short moving window to reduce noise. +4. Compare the smoothed EAR to a threshold (e.g., 0.23). +5. If EAR stays below the threshold for a minimum number of consecutive frames (e.g., 15), flag a drowsiness event. +6. Start and end timestamps are recorded for each event. If tracking is lost or the stream ends during an event, it is finalized accordingly. + +## Outputs +- Events are saved with start/end timestamps and summary metrics under `src/my-service/result-reports/`. diff --git a/btkach-demo-drowsiness/installation_steps.md b/btkach-demo-drowsiness/installation_steps.md new file mode 100644 index 0000000..352ae8a --- /dev/null +++ b/btkach-demo-drowsiness/installation_steps.md @@ -0,0 +1,16 @@ +mount -o remount,rw / + + +copy requirement.txt to VM +run pip3 install -r req.txt + + + +clean up: + systemctl stop aos.target + +systemctl | grep aos + + rm -rf /var/aos/workdirs/sm + +systemctl start aos.target \ No newline at end of file diff --git a/btkach-demo-drowsiness/meta/config.yaml b/btkach-demo-drowsiness/meta/config.yaml new file mode 100644 index 0000000..30159cf --- /dev/null +++ b/btkach-demo-drowsiness/meta/config.yaml @@ -0,0 +1,19 @@ +publisher: + author: "Bogdan Tkach" + company: "Epam Systems" + +build: + os: linux + arch: x86 + symlinks: copy + sign_pkcs12: aos-user-sp.p12 + +publish: + url: aoscloud.io + service_uid: de79d870-6507-4fd9-b92b-723bd7a27274 + tls_pkcs12: aos-user-sp.p12 + version: "1.0.7" + +configuration: + cmd: /usr/bin/python3 -u my-service/main.py + workingDir: "/" diff --git a/btkach-demo-drowsiness/publish.ps1 b/btkach-demo-drowsiness/publish.ps1 new file mode 100644 index 0000000..23ced74 --- /dev/null +++ b/btkach-demo-drowsiness/publish.ps1 @@ -0,0 +1,3 @@ +~/.aos/venv/Scripts/python -m aos_signer sign + +~/.aos/venv/Scripts/python -m aos_signer upload \ No newline at end of file diff --git a/btkach-demo-drowsiness/src/__init__.py b/btkach-demo-drowsiness/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/btkach-demo-drowsiness/src/my-service/__init__.py b/btkach-demo-drowsiness/src/my-service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/btkach-demo-drowsiness/src/my-service/main.py b/btkach-demo-drowsiness/src/my-service/main.py new file mode 100644 index 0000000..6a6f69b --- /dev/null +++ b/btkach-demo-drowsiness/src/my-service/main.py @@ -0,0 +1,233 @@ +import os +# Ensure Matplotlib cache dir is set and created before any third-party imports +MPL_CONFIG_DIR = "/storage/.cache/matplotlib" +os.environ["MPLCONFIGDIR"] = MPL_CONFIG_DIR +try: + # Create the directory if it doesn't exist; ignore errors if concurrent + from pathlib import Path as _Path + _Path(MPL_CONFIG_DIR).mkdir(parents=True, exist_ok=True) +except Exception: + # If creation fails, proceed; Matplotlib will still attempt to use the env var + pass + +import json +import logging +import math +import sys +import time # new import for cycle delay +from collections import deque +from pathlib import Path +from typing import List, Optional +from datetime import datetime, UTC + +import cv2 +import mediapipe as mp + + +# Hardcoded config keeps edge deployment simple and self-contained. +BASE_DIR = Path(__file__).parent +TEST_DATA_DIR = BASE_DIR / "test-data" +# REPORT_DIR = BASE_DIR / "result-reports" +REPORT_DIR = Path("/storage/result-reports") +LOG_LEVEL = "DEBUG" +EAR_THRESHOLD = 0.23 +CONSEC_FRAMES = 15 +SMOOTHING_WINDOW = 5 +LEFT_EYE_IDX = [33, 160, 158, 133, 153, 144] +RIGHT_EYE_IDX = [263, 387, 385, 362, 380, 373] +VIDEO_EXTENSIONS = {".mp4", ".webm", ".avi", ".mov", ".mkv"} + + +def _euclidean(p1, p2) -> float: + return math.dist((p1.x, p1.y), (p2.x, p2.y)) + + +def _compute_ear(landmarks, eye_idx: List[int]) -> float: + p1, p2, p3, p4, p5, p6 = [landmarks[i] for i in eye_idx] + vertical1 = _euclidean(p2, p6) + vertical2 = _euclidean(p3, p5) + horizontal = _euclidean(p1, p4) + return (vertical1 + vertical2) / (2.0 * horizontal) + + +def _log_frame(logger: logging.Logger, frame_index: int, fps: float, smoothed_ear: Optional[float]): + if fps <= 0: + return + if frame_index % max(int(fps // 2) or 1, 1) == 0: + logger.debug( + "Frame %d | timestamp: %.2fs | smoothed EAR: %s", + frame_index, + frame_index / fps, + f"{smoothed_ear:.3f}" if smoothed_ear is not None else "None", + ) + + +def _build_report(video_path: Path, fps: float, events: List[dict]) -> dict: + return { + "video": str(video_path), + "fps": fps, + "events": events, + "threshold": EAR_THRESHOLD, + "consec_frames": CONSEC_FRAMES, + "smoothing_window": SMOOTHING_WINDOW, + } + + +def _write_report(report: dict, destination: Path, logger: logging.Logger) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) + with destination.open("w", encoding="utf-8") as f: + json.dump(report, f, indent=2) + logger.info("Report written to %s", destination) + + +def _finalize_event(events: List[dict], start_ts: float, end_ts: float, logger: logging.Logger, note: str) -> None: + events.append({"start": float(start_ts), "end": float(end_ts)}) + logger.info("Event recorded: start %.2fs end %.2fs (%s)", start_ts, end_ts, note) + + +def _analyze_video(video_path: Path, logger: logging.Logger) -> dict: + logger.info("Starting analysis for video: %s", video_path) + if not video_path.exists(): + raise FileNotFoundError(f"Video file not found: {video_path}") + + mp_face_mesh = mp.solutions.face_mesh + face_mesh = mp_face_mesh.FaceMesh(max_num_faces=1) + cap = cv2.VideoCapture(str(video_path)) + fps = cap.get(cv2.CAP_PROP_FPS) or 0.0 + if fps <= 0: + logger.warning("FPS not reported by video; defaulting to 30.0") + fps = 30.0 + + frame_index = 0 + ear_history: deque[float] = deque(maxlen=SMOOTHING_WINDOW) + frame_counter = 0 + events = [] + current_event_start = None + + logger.debug( + "Config -> threshold: %.3f, consec_frames: %d, smoothing_window: %d", + EAR_THRESHOLD, + CONSEC_FRAMES, + SMOOTHING_WINDOW, + ) + + try: + while True: + ret, frame = cap.read() + if not ret: + break + + frame_index += 1 + timestamp = frame_index / fps + rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + result = face_mesh.process(rgb) + + if result.multi_face_landmarks: + lm = result.multi_face_landmarks[0].landmark + left_ear = _compute_ear(lm, LEFT_EYE_IDX) + right_ear = _compute_ear(lm, RIGHT_EYE_IDX) + ear = (left_ear + right_ear) / 2.0 + ear_history.append(ear) + smoothed_ear = sum(ear_history) / len(ear_history) + else: + smoothed_ear = None + + _log_frame(logger, frame_index, fps, smoothed_ear) + + if smoothed_ear is None: + if frame_counter >= CONSEC_FRAMES and current_event_start is not None: + _finalize_event(events, current_event_start, timestamp, logger, "tracking lost") + frame_counter = 0 + current_event_start = None + continue + + if smoothed_ear < EAR_THRESHOLD: + frame_counter += 1 + if frame_counter == CONSEC_FRAMES: + current_event_start = timestamp - (CONSEC_FRAMES / fps) + logger.info("Potential drowsiness detected starting at %.2fs", current_event_start) + else: + if frame_counter >= CONSEC_FRAMES and current_event_start is not None: + _finalize_event(events, current_event_start, timestamp, logger, "eyes reopened") + frame_counter = 0 + current_event_start = None + + if frame_counter >= CONSEC_FRAMES and current_event_start is not None: + _finalize_event(events, current_event_start, frame_index / fps, logger, "video ended") + finally: + cap.release() + + report = _build_report(video_path, fps, events) + logger.info("Analysis complete. %d drowsiness events detected.", len(events)) + logger.debug(json.dumps(report, indent=2)) + return report + + +def _collect_videos(directory: Path) -> List[Path]: + if not directory.exists(): + raise FileNotFoundError(f"Test data directory not found: {directory}") + videos = sorted( + [p for p in directory.iterdir() if p.suffix.lower() in VIDEO_EXTENSIONS and p.is_file()] + ) + if not videos: + raise FileNotFoundError(f"No video files found in: {directory}") + return videos + + +def main() -> int: + # Verbose logging helps trace processing on constrained devices. + logging.basicConfig( + level=getattr(logging, LOG_LEVEL, logging.INFO), + format="%(asctime)s %(levelname)s %(name)s - %(message)s", + ) + logger = logging.getLogger("drowsiness-demo") + + overall_status = 0 + logger.info("Starting continuous monitoring loop. App version is 1.0.7") + + try: + while True: + try: + videos = _collect_videos(TEST_DATA_DIR) + except FileNotFoundError as exc: + logger.error(str(exc)) + overall_status = 1 + break + + logger.info("Found %d video(s) in %s", len(videos), TEST_DATA_DIR) + + run_timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") + logger.info("Processing cycle timestamp: %s", run_timestamp) + + cycle_status = 0 + + for video_path in videos: + report_path = REPORT_DIR / f"{video_path.stem}_{run_timestamp}.json" + logger.info("App version is 1.0.7. Processing video %s", video_path) + try: + report = _analyze_video(video_path, logger) + _write_report(report, report_path, logger) + print(json.dumps(report, indent=2)) + except FileNotFoundError as exc: + logger.error(str(exc)) + cycle_status = 1 + except Exception: + logger.exception("Unexpected error during analysis of %s", video_path) + cycle_status = 1 + + if cycle_status == 0: + logger.info("Cycle complete. All reports saved to %s", REPORT_DIR) + else: + logger.warning("Cycle %s completed with errors.", run_timestamp) + + overall_status = cycle_status + logger.info("Sleeping 10 seconds before the next cycle.") + time.sleep(10) + except KeyboardInterrupt: + logger.info("Continuous monitoring interrupted by user.") + + return overall_status + + +if __name__ == "__main__": + main() diff --git a/btkach-demo-drowsiness/src/my-service/test-data/sample_1.mp4 b/btkach-demo-drowsiness/src/my-service/test-data/sample_1.mp4 new file mode 100644 index 0000000..2c7cdc0 Binary files /dev/null and b/btkach-demo-drowsiness/src/my-service/test-data/sample_1.mp4 differ diff --git a/btkach-demo-drowsiness/src/my-service/test-data/sample_2.mp4 b/btkach-demo-drowsiness/src/my-service/test-data/sample_2.mp4 new file mode 100644 index 0000000..8bd2491 Binary files /dev/null and b/btkach-demo-drowsiness/src/my-service/test-data/sample_2.mp4 differ diff --git a/btkach-demo-drowsiness/src/my-service/test-data/sample_3.mp4 b/btkach-demo-drowsiness/src/my-service/test-data/sample_3.mp4 new file mode 100644 index 0000000..f162f35 Binary files /dev/null and b/btkach-demo-drowsiness/src/my-service/test-data/sample_3.mp4 differ diff --git a/btkach-demo-drowsiness/src/my-service/test-data/sample_4.mp4 b/btkach-demo-drowsiness/src/my-service/test-data/sample_4.mp4 new file mode 100644 index 0000000..1d87701 Binary files /dev/null and b/btkach-demo-drowsiness/src/my-service/test-data/sample_4.mp4 differ diff --git a/btkach-demo-drowsiness/src/requirements.txt b/btkach-demo-drowsiness/src/requirements.txt new file mode 100644 index 0000000..10c3df9 --- /dev/null +++ b/btkach-demo-drowsiness/src/requirements.txt @@ -0,0 +1,3 @@ +mediapipe==0.10.21 +opencv-python-headless<=4.12.0.88 + diff --git a/btkach-demo-service/meta/config.yaml b/btkach-demo-service/meta/config.yaml new file mode 100644 index 0000000..0089323 --- /dev/null +++ b/btkach-demo-service/meta/config.yaml @@ -0,0 +1,20 @@ +publisher: + author: "Bogdan Tkach" + company: "Epam Systems" + +build: + os: linux + arch: x86 + symlinks: copy + sign_pkcs12: aos-user-sp.p12 + +publish: + url: aoscloud.io + service_uid: e6e2940f-3be0-4ea6-84b2-e04833953f59 + tls_pkcs12: aos-user-sp.p12 + version: "1.0.1" + +configuration: + # Use Python from the AoS venv to ensure consistent deps + cmd: venv/bin/python -u main.py + workingDir: "/my-service" diff --git a/btkach-demo-service/publish.ps1 b/btkach-demo-service/publish.ps1 new file mode 100644 index 0000000..23ced74 --- /dev/null +++ b/btkach-demo-service/publish.ps1 @@ -0,0 +1,3 @@ +~/.aos/venv/Scripts/python -m aos_signer sign + +~/.aos/venv/Scripts/python -m aos_signer upload \ No newline at end of file diff --git a/btkach-demo-service/requirements.txt b/btkach-demo-service/requirements.txt new file mode 100644 index 0000000..0955bc8 --- /dev/null +++ b/btkach-demo-service/requirements.txt @@ -0,0 +1,8 @@ +# Offline inference dependencies for models +transformers==4.44.0 +accelerate==0.33.0 +bitsandbytes==0.44.1 +safetensors==0.4.3 +torch==2.2.1 +# If bitsandbytes (GPU 4-bit) not supported on target, set PHI2_4BIT=0 and keep torch CPU. + diff --git a/btkach-demo-service/src/my-service/README.md b/btkach-demo-service/src/my-service/README.md new file mode 100644 index 0000000..f78d38d --- /dev/null +++ b/btkach-demo-service/src/my-service/README.md @@ -0,0 +1,108 @@ +# Edge Vehicle Telemetry Language Model Demo (Phi-2) + +This demo service ingests local CSV telemetry snapshots and generates a health & driving style report using the small `phi-2` language model entirely offline. No external runtime (like Ollama) is required; the Hugging Face `transformers` library loads weights from the local `phi-2/` directory. + +## What It Does +1. Loads telemetry CSV files from `test-data/`. +2. Computes summary statistics (avg/min/max) for key metrics. +3. Builds a domain prompt describing current vehicle state. +4. Uses `phi-2` (optionally 4-bit or dynamic int8 quantized) to produce an assessment. +5. Stores a structured JSON report under `result-reports/`. + +## Model & Memory +- To stay under 4 GB RAM, the code first attempts a 4-bit load (`load_in_4bit=True` via bitsandbytes). +- If 4-bit quantization fails (e.g., no GPU / bitsandbytes incompatibility) it will attempt dynamic int8 quantization on Linear layers. +- Set `PHI2_4BIT=0` to skip 4-bit attempt. +- Set `PHI2_INT8_FALLBACK=0` to disable int8 fallback. + +## Required Offline Files +Place an offline copy of `microsoft/phi-2` model/tokenizer files in `phi-2/`. See `phi-2/README.md` for details and quantization instructions. + +## Environment Flags +| Flag | Default | Purpose | +|------|---------|---------| +| `PHI2_DISABLE` | 0 | Skip loading model entirely (testing). | +| `PHI2_4BIT` | 1 | Attempt 4-bit quantized load first. | +| `PHI2_INT8_FALLBACK` | 1 | Apply dynamic int8 quantization if 4-bit fails. | +| `LM_SEED` | unset | Deterministic sampling seed. | + +## Running (Development) +```bash +# Generate one report +PHI2_DISABLE=0 python my-service/main.py +``` +For continuous operation, call `main_loop()` instead of single run (modify bottom of `main.py`). + +Windows CMD example: +```cmd +set PHI2_DISABLE=0 +python my-service\main.py +``` + +## Venv / Packaging Guidance +Create the venv on the target platform before signing/uploading to ensure native wheels match the edge device. + +Linux: +```bash +rm -rf ~/.aos/venv +python3 -m venv ~/.aos/venv +~/.aos/venv/bin/pip install -r btkach-demo-service/requirements.txt +# Place models weights into btkach-demo-service/src/my-service/models/ +~/.aos/venv/bin/python -m aos_signer sign +~/.aos/venv/bin/python -m aos_signer upload +``` + +Windows PowerShell: +```powershell +Remove-Item -Recurse -Force $env:USERPROFILE\.aos\venv +python -m venv $env:USERPROFILE\.aos\venv +$env:USERPROFILE\.aos\venv\Scripts\pip.exe install -r btkach-demo-service\requirements.txt +# Copy phi-2 weights into btkach-demo-service\src\my-service\phi-2\ +$env:USERPROFILE\.aos\venv\Scripts\python.exe -m aos_signer sign +$env:USERPROFILE\.aos\venv\Scripts\python.exe -m aos_signer upload +``` + +## Offline Dependency Vendoring +To package all Python libs inside the `src` zip (so the edge device does not need to download wheels), vendor them: + +Linux / macOS: +```bash +python3 -m pip install --upgrade pip +python3 -m pip install --target btkach-demo-service/src/my-service/vendor -r btkach-demo-service/requirements.txt +``` +Windows (PowerShell): +```powershell +pip install --upgrade pip +pip install --target btkach-demo-service/src/my-service/vendor -r btkach-demo-service/requirements.txt +``` +This creates `my-service/vendor/` with all required packages. The service adds this path to `sys.path` automatically. + +Then add phi-2 weights to `my-service/phi-2/` and proceed with signing. + +## Minimal Runtime Zip +Ensure the following directories exist inside the zipped `src`: +- `my-service/main.py` +- `my-service/vendor/` (Python dependencies) +- `my-service/phi-2/` (model weights & tokenizer) +- `my-service/test-data/` (CSV telemetry samples) +- `my-service/result-reports/` (created at runtime) + +## Report Format +Each JSON report contains: +- `generated_at` (UTC timestamp) +- `summary` (statistics per metric) +- `prompt` (constructed telemetry prompt) +- `analysis` (phi-2 generated assessment or fallback message) + +Example filename: `report_20251113_100530.json`. + +## Offline Operation +All required runtime dependencies and model weights reside inside the zipped `src` folder; no network access is needed at inference time. + +## Extending +Add new telemetry metrics as columns in `test-data/*.csv`; extend the `metrics` dict in `main.py` to summarize them. + +## Troubleshooting +- If you see `[Phi-2 weights missing...]`, ensure model files are present in `phi-2/`. +- For CPU-only environments where bitsandbytes fails, confirm dynamic int8 fallback log appears. +- To measure memory usage, run under `python -m memory_profiler my-service/main.py` (after installing `memory_profiler`). diff --git a/btkach-demo-service/src/my-service/__init__.py b/btkach-demo-service/src/my-service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/btkach-demo-service/src/my-service/main.py b/btkach-demo-service/src/my-service/main.py new file mode 100644 index 0000000..82ca635 --- /dev/null +++ b/btkach-demo-service/src/my-service/main.py @@ -0,0 +1,264 @@ +import os +import time +import csv +import json +import math +import datetime +from pathlib import Path + +# Directories +BASE_DIR = Path(__file__).parent +TEST_DATA_DIR = BASE_DIR / "test-data" +REPORT_DIR = BASE_DIR / "result-reports" +MODELS_DIR = BASE_DIR / "models" +# Name of the local transformers model directory inside MODELS_DIR +MODEL_NAME = os.environ.get("LOCAL_MODEL_NAME", "tinyllama") +LOCAL_MODEL_DIR = MODELS_DIR / MODEL_NAME +REPORT_DIR.mkdir(exist_ok=True) + +# Backend configuration +MODEL_RUNNER = os.environ.get("MODEL_RUNNER", "transformers").lower() # "ollama" (default) or "transformers" + +def load_speed_data(): + """Load all test_speed_*.csv files from test-data/""" + files = {} + for path in TEST_DATA_DIR.glob("test_speed_*.csv"): + with open(path, newline="") as f: + reader = csv.DictReader(f) + rows = [row for row in reader] + files[path.name] = rows + return files + + +def to_float(val): + try: + return float(val) + except (TypeError, ValueError): + return float("nan") + + +def summarize_speed(rows): + speeds = [] + for row in rows: + speed = to_float(row.get("speed_kmh")) + if not math.isnan(speed): + speeds.append(speed) + speed_limit = 50.0 + speeding_count = sum(1 for s in speeds if s > speed_limit) + return { + "count": len(speeds), + "avg_speed": sum(speeds) / len(speeds) if speeds else 0, + "min_speed": min(speeds) if speeds else 0, + "max_speed": max(speeds) if speeds else 0, + "speed_limit": speed_limit, + "speeding_instances": speeding_count, + "speeding_percentage": (speeding_count / len(speeds)) * 100 if speeds else 0, + "speed_records": speeds, + } + + +def build_prompt(summary): + speed_records = ", ".join([f"{s:.0f}" for s in summary.get("speed_records", [])]) + speeding_instances = summary.get("speeding_instances", 0) + speeding_percentage = summary.get("speeding_percentage", 0.0) + speed_limit = summary.get("speed_limit", 50) + prompt = ( + f"Input: \"Given the speed sensor records (km/h at 5-second intervals): [{speed_records}]. " + f"Speed limit is {speed_limit} km/h. " + f"speeding_instances={speeding_instances} (count of individual measurements exceeding the speed limit). " + f"speeding_percentage={speeding_percentage:.2f}% (percentage of all valid measurements above the limit). " + f"Provide a text analysis of driver behaviour, focusing on frequency, severity, consistency of speeding and potential safety risk.\"" + f"Output:" + ) + return prompt + + +# ---------------------------- +# Transformers backend +# ---------------------------- + +_transformers_cache = {"tokenizer": None, "model": None, "device": None} + + +def _load_transformers(): + """Load model using local transformers weights from models/.""" + try: + from transformers import AutoTokenizer, AutoModelForCausalLM + import torch + + print(f"[models][transformers] Loading model and tokenizer from local directory '{LOCAL_MODEL_DIR}'...") + tokenizer = AutoTokenizer.from_pretrained( + str(LOCAL_MODEL_DIR), local_files_only=True, trust_remote_code=True + ) + model = AutoModelForCausalLM.from_pretrained( + str(LOCAL_MODEL_DIR), + local_files_only=True, + trust_remote_code=True, + torch_dtype=torch.float32, + low_cpu_mem_usage=True, + ) + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + model = model.to(device) + model.eval() + + _transformers_cache["tokenizer"] = tokenizer + _transformers_cache["model"] = model + _transformers_cache["device"] = device + print("[models][transformers] Model loaded successfully (full precision).") + except Exception as e: + print(f"[models][transformers] Model load failed or not present in '{LOCAL_MODEL_DIR}': {e}") + return _transformers_cache + + +def _generate_with_transformers(prompt, max_new_tokens=128): + """Generate analysis text using local transformers model.""" + cache = _load_transformers() + tokenizer = cache.get("tokenizer") + model = cache.get("model") + device = cache.get("device") + if tokenizer is None or model is None or device is None: + return ( + "[Transformers backend not available: place model files under 'models//' " + "(see README) and ensure dependencies are installed.]" + ), "none" + import torch + + inputs = tokenizer(prompt, return_tensors="pt").to(device) + with torch.no_grad(): + output_ids = model.generate( + **inputs, + max_new_tokens=max_new_tokens, + temperature=0.7, + top_p=0.9, + do_sample=True, + pad_token_id=tokenizer.eos_token_id, + ) + result = tokenizer.decode(output_ids[0], skip_special_tokens=True) + return result, str(device) + + +# ---------------------------- +# Ollama backend +# ---------------------------- + + +def _generate_with_ollama(prompt, max_new_tokens=128): + """Generate analysis text using the Ollama Python client. + """ + try: + from ollama import chat + except ImportError as e: + msg = ( + "[models][ollama] Python package 'ollama' is not installed. " + "Install it with 'pip install ollama' to use the Ollama backend." + ) + print(msg) + return msg, "ollama" + + try: + # Use the chat API with a single user message containing our prompt. + # max_new_tokens maps naturally to `num_predict` in Ollama options. + response = chat( + model=MODEL_NAME, + messages=[ + { + "role": "user", + "content": prompt, + } + ], + options={ + "temperature": 0.7, + "top_p": 0.9, + "num_predict": max_new_tokens, + }, + ) + + # Access the content field in a robust way, supporting both dict-style and + # attribute-style access depending on the installed ollama client version. + content = None + try: + # Newer versions expose a .message.content attribute + content = getattr(getattr(response, "message", None), "content", None) + except Exception: + content = None + if not content: + # Fallback to dict-style access if the response is subscriptable + try: + content = response["message"]["content"] + except Exception: + content = str(response) + + return content, "ollama" + except Exception as e: + print(f"[models][ollama] Error calling Ollama client for model '{MODEL_NAME}': {e}") + return ( + f"[Ollama backend error: {e}. Ensure Ollama is running and the model " + f"'{MODEL_NAME}' is available.]" + ), "ollama" + + +# ---------------------------- +# Unified generation API +# ---------------------------- + + +def generate_analysis(prompt, max_new_tokens=128): + """Generate analysis using the selected backend. + + Backend is chosen via MODEL_RUNNER env var ("ollama" or "transformers"). + Default is "ollama". + """ + backend = MODEL_RUNNER + + # Log prompt and backend + print("[models] Backend:", backend) + print("[models] Local model directory:", LOCAL_MODEL_DIR) + print("[models] Ollama model:", MODEL_NAME) + print("[models] Prompt:", prompt) + + start = time.time() + if backend == "ollama": + result, device = _generate_with_ollama(prompt, max_new_tokens=max_new_tokens) + elif backend == "transformers": + result, device = _generate_with_transformers(prompt, max_new_tokens=max_new_tokens) + else: + print(f"[models] Unknown MODEL_RUNNER='{backend}', falling back to 'ollama'.") + result, device = _generate_with_ollama(prompt, max_new_tokens=max_new_tokens) + elapsed = time.time() - start + + print(f"[models] Used device/backend identifier: {device}") + print(f"[models] Generation took {elapsed:.2f} seconds") + print("[models] Result:", result) + + return result + + +def generate_reports(): + files_data = load_speed_data() + if not files_data: + print("No test data files found (test_speed_*.csv)") + return + now_utc = datetime.datetime.now(datetime.timezone.utc) + timestamp_str = now_utc.strftime("%Y%m%d_%H%M%S") + all_reports = {} + for filename, rows in files_data.items(): + print(f"\n{'=' * 60}\nProcessing: {filename}\n{'=' * 60}") + summary = summarize_speed(rows) + prompt = build_prompt(summary) + analysis = generate_analysis(prompt) + report = { + "generated_at": now_utc.isoformat(), + "source_file": filename, + "summary": summary, + "prompt": prompt, + "analysis": analysis, + } + all_reports[filename] = report + combined_report = {"generated_at": now_utc.isoformat(), "reports": all_reports} + out_path = REPORT_DIR / f"report_{timestamp_str}.json" + out_path.write_text(json.dumps(combined_report, indent=2), encoding="utf-8") + print(f"\n{'=' * 60}\nCombined report written to {out_path}\n{'=' * 60}") + + +if __name__ == "__main__": + generate_reports() diff --git a/btkach-demo-service/src/my-service/models/README.md b/btkach-demo-service/src/my-service/models/README.md new file mode 100644 index 0000000..05ad276 --- /dev/null +++ b/btkach-demo-service/src/my-service/models/README.md @@ -0,0 +1,48 @@ +# Phi-2 Offline Weights +Place an offline copy of the `microsoft/phi-2` model files in this directory for deployment without internet. + +## Downloading the Model +To download the full Phi-2 model (~5.5GB), run the preparation script: + +```bash +cd models +pip install transformers torch safetensors +python download_model.py +``` + +This will create a `phi2/` directory with all necessary model files. Copy the contents of `phi2/` into this `phi-2/` directory. + +## Required Files +After preparation, this directory should contain: +- config.json +- tokenizer.json (and related tokenizer files) +- generation_config.json +- model.safetensors (or pytorch model files) + +## Memory Requirements +The full, unquantized Phi-2 model will use approximately **5-6 GB of RAM** when loaded. Make sure your edge device has sufficient memory available. + +## Environment Flags +- `PHI2_DISABLE=1` - Skip loading the model (useful for testing without weights) + +## Verification +Run the main application to test: +```bash +python my-service/main.py +``` +If weights exist, you should see `[phi-2] Model loaded successfully (full precision).` + +## Licensing +Review licensing/terms of use for `microsoft/phi-2` before redistribution inside a vehicle edge deployment. + + +## Verification +Run: +```bash +PHI2_DISABLE=0 PHI2_4BIT=1 python my-service/main.py +``` +If weights exist, you should see `Phi-2 model loaded (4bit=True).` + +## Licensing +Review licensing/terms of use for `microsoft/phi-2` before redistribution inside a vehicle edge deployment. + diff --git a/btkach-demo-service/src/my-service/models/download_model.py b/btkach-demo-service/src/my-service/models/download_model.py new file mode 100644 index 0000000..551629f --- /dev/null +++ b/btkach-demo-service/src/my-service/models/download_model.py @@ -0,0 +1,53 @@ +from pathlib import Path +import os +import sys + +from transformers import AutoModelForCausalLM, AutoTokenizer + + +def main(): + """Download any transformers-supported causal LM and tokenizer into models/. + + Usage (examples): + # Use env vars + MODEL_ID=phi-2 LOCAL_MODEL_NAME=phi2 python download_model.py + + # Or via CLI args + python download_model.py phi-2 phi2 + """ + + # Prefer CLI args; fall back to environment variables; finally defaults. + if len(sys.argv) >= 3: + model_id = sys.argv[1] + local_name = sys.argv[2] + else: + model_id = os.environ.get("MODEL_ID", "TinyLlama/TinyLlama-1.1B-Chat-v1.0") + local_name = os.environ.get("LOCAL_MODEL_NAME", "tinyllama") + + base_dir = Path(__file__).resolve().parent + models_dir = base_dir + output_dir = models_dir / local_name + + # Create output directory if it doesn't exist + output_dir.mkdir(parents=True, exist_ok=True) + + print(f"[download_model] Downloading model and tokenizer from '{model_id}'...") + print(f"[download_model] Target directory: {output_dir}") + + # Download model and tokenizer + model = AutoModelForCausalLM.from_pretrained( + model_id, + trust_remote_code=True, + torch_dtype="auto", + ) + tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True) + + # Save to local directory + model.save_pretrained(output_dir) + tokenizer.save_pretrained(output_dir) + + print(f"[download_model] Model and tokenizer saved to '{output_dir}/' directory.") + + +if __name__ == "__main__": + main() diff --git a/btkach-demo-service/src/my-service/test-data/test_speed_bad_driver.csv b/btkach-demo-service/src/my-service/test-data/test_speed_bad_driver.csv new file mode 100644 index 0000000..cad717e --- /dev/null +++ b/btkach-demo-service/src/my-service/test-data/test_speed_bad_driver.csv @@ -0,0 +1,22 @@ +timestamp,speed_kmh +0,48 +5,52 +10,55 +15,49 +20,58 +25,47 +30,51 +35,62 +40,48 +45,54 +50,49 +55,67 +60,51 +65,48 +70,59 +75,50 +80,53 +85,71 +90,56 +95,49 + diff --git a/btkach-demo-service/src/my-service/test-data/test_speed_good_driver.csv b/btkach-demo-service/src/my-service/test-data/test_speed_good_driver.csv new file mode 100644 index 0000000..938cce6 --- /dev/null +++ b/btkach-demo-service/src/my-service/test-data/test_speed_good_driver.csv @@ -0,0 +1,22 @@ +timestamp,speed_kmh +0,45 +5,48 +10,47 +15,49 +20,46 +25,50 +30,48 +35,47 +40,49 +45,46 +50,48 +55,47 +60,49 +65,48 +70,47 +75,49 +80,48 +85,47 +90,49 +95,48 +