Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions docs/standardized_output.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Standardized Emotion Output Adapter

## Purpose

This module provides a lightweight adapter that converts the facial emotion analysis results produced by this API into the **RUXAILAB Standardized Output Schema (v1.0)**.

The standardized schema was proposed in the sibling repository [`sentiment-analysis-api`](https://github.com/ruxailab/sentiment-analysis-api) to unify outputs across different analysis services (text sentiment, audio transcript sentiment, and facial emotion detection). By producing the same structure, any downstream consumer (frontend dashboards, Firestore storage, cross-modality comparison tools) can process results from this API without format-specific logic.

## How It Works

The adapter lives in the `normalization/` package:

- **`normalization/schema.py`** — Pydantic models defining the standardized schema (`StandardizedOutput`, `ResultEntry`, `Segment`).
- **`normalization/adapter.py`** — The `normalize_emotions()` function that performs the conversion.

### Conversion steps

1. Accepts a `GetEmotionPercentagesResponse` object (or equivalent dict) as returned by `EmotionsAnalysisImp.get_emotion_percentages()`.
2. Maps emotion labels from title-case (`Happy`, `Angry`, …) to lowercase (`happy`, `angry`, …).
3. Converts percentage scores (0–100) to normalized scores (0.0–1.0). Scores already in the 0–1 range are kept as-is.
4. Sorts results by score in descending order so the dominant emotion appears first.
5. Returns a validated `StandardizedOutput` instance.

### Usage example

```python
from schemas.emotion_schema import GetEmotionPercentagesResponse
from normalization.adapter import normalize_emotions

# After running emotion analysis on a video...
percentages = GetEmotionPercentagesResponse(
Angry=4.0, Disgusted=1.0, Fearful=2.0,
Happy=45.0, Neutral=30.0, Sad=6.0, Surprised=12.0,
)

standardized = normalize_emotions(
emotion_data=percentages,
video_path="recording_task3.mp4",
task_id="usability-session-42",
)

# Serialize to JSON for storage or API response
print(standardized.model_dump_json(indent=2))
```

### Output

```json
{
"schema_version": "1.0",
"analysis_type": "emotion",
"modality": "facial",
"source_model": "facial-emotion-model2",
"timestamp": "2026-03-09T14:30:00Z",
"task_id": "usability-session-42",
"input_summary": "recording_task3.mp4",
"results": [
{ "label": "happy", "score": 0.45, "intensity": null, "segment": null },
{ "label": "neutral", "score": 0.30, "intensity": null, "segment": null },
{ "label": "surprised", "score": 0.12, "intensity": null, "segment": null },
{ "label": "sad", "score": 0.06, "intensity": null, "segment": null },
{ "label": "angry", "score": 0.04, "intensity": null, "segment": null },
{ "label": "fearful", "score": 0.02, "intensity": null, "segment": null },
{ "label": "disgusted", "score": 0.01, "intensity": null, "segment": null }
]
}
```

## Schema Fields

| Field | Type | Description |
|-------|------|-------------|
| `schema_version` | `string` | Always `"1.0"` for this version. |
| `analysis_type` | `string` | `"emotion"` for facial analysis. |
| `modality` | `string` | `"facial"` for this API. |
| `source_model` | `string` | Identifier of the model used (default: `"facial-emotion-model2"`). |
| `timestamp` | `string` | ISO 8601 datetime of when the analysis was performed. |
| `task_id` | `string \| null` | Optional usability test task or session identifier. |
| `input_summary` | `string` | Path or identifier of the analyzed video. |
| `results` | `array` | Emotion entries sorted by score descending. |

Each result entry contains:

| Field | Type | Description |
|-------|------|-------------|
| `label` | `string` | Lowercase emotion label (`happy`, `sad`, `angry`, etc.). |
| `score` | `float` | Normalized score between 0.0 and 1.0. |
| `intensity` | `string \| null` | Reserved for future use. |
| `segment` | `object \| null` | Reserved for future per-segment analysis. |

## Integration with RUXAILAB

This adapter enables:

- **Unified frontend components**: The RUXAILAB frontend can render facial emotion results using the same components that display text and audio sentiment.
- **Consistent Firestore storage**: Results from all modalities share the same document structure.
- **Cross-modality comparison**: Standardized scores make it possible to compare or aggregate results across text, audio, and facial analysis for the same usability session.
- **Task-level linking**: The optional `task_id` field connects results to specific usability test tasks, enabling temporal correlation across modalities.

## Running Tests

```bash
pytest tests/test_adapter.py -v
```
Empty file added normalization/__init__.py
Empty file.
93 changes: 93 additions & 0 deletions normalization/adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""
Adapter that converts facial emotion analysis results into the
standardized RUXAILAB output schema.

This module bridges the gap between the emotion percentages produced
by this API and the unified schema used across the RUXAILAB platform
(text, audio, and facial analysis services).
"""

from datetime import datetime, timezone
from typing import Optional, Union

from schemas.emotion_schema import GetEmotionPercentagesResponse
from normalization.schema import StandardizedOutput, ResultEntry


# Mapping from the title-case labels used by this API to the lowercase
# labels defined in the standardized schema.
EMOTION_LABEL_MAP = {
"Angry": "angry",
"Disgusted": "disgusted",
"Fearful": "fearful",
"Happy": "happy",
"Neutral": "neutral",
"Sad": "sad",
"Surprised": "surprised",
}


def _now_iso() -> str:
"""Return the current UTC time as an ISO 8601 string."""
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def normalize_emotions(
emotion_data: Union[GetEmotionPercentagesResponse, dict],
video_path: str,
source_model: str = "facial-emotion-model2",
task_id: Optional[str] = None,
timestamp: Optional[str] = None,
) -> StandardizedOutput:
"""Convert facial emotion percentages to the standardized schema.

Parameters
----------
emotion_data : GetEmotionPercentagesResponse or dict
Emotion percentages as returned by the emotion analysis service.
Values are expected in the 0-100 range (percentages) but 0-1
values are also accepted.
video_path : str
Path or identifier of the video that was analyzed.
source_model : str
Identifier for the model that produced the result.
task_id : str or None
Optional task or session identifier for usability testing.
timestamp : str or None
ISO 8601 timestamp. If not provided the current UTC time is used.

Returns
-------
StandardizedOutput
A validated instance of the standardized schema.
"""
if isinstance(emotion_data, GetEmotionPercentagesResponse):
percentages = emotion_data.model_dump()
else:
percentages = dict(emotion_data)

results = []
for raw_label, score in percentages.items():
normalized_label = EMOTION_LABEL_MAP.get(raw_label, raw_label.lower())
# Convert 0-100 percentages to 0-1 scores
if score > 1.0:
score = score / 100.0
results.append(
ResultEntry(
label=normalized_label,
score=round(score, 4),
)
)

# Sort by score descending so the dominant emotion comes first
results.sort(key=lambda r: r.score, reverse=True)

return StandardizedOutput(
analysis_type="emotion",
modality="facial",
source_model=source_model,
timestamp=timestamp or _now_iso(),
task_id=task_id,
input_summary=video_path,
results=results,
)
65 changes: 65 additions & 0 deletions normalization/schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""
Standardized output schema for sentiment and emotion analysis results.

Defines Pydantic models representing the v1 schema documented in the
RUXAILAB sentiment-analysis-api (docs/emotion_output_schema_v1.md).
These models allow the facial emotion API to produce outputs compatible
with the unified format used across the RUXAILAB stack.
"""

from typing import List, Optional, Literal
from pydantic import BaseModel, Field


class Segment(BaseModel):
"""Temporal and textual segment associated with a result entry."""
start: float = Field(..., description="Start time in seconds")
end: float = Field(..., description="End time in seconds")
text: Optional[str] = Field(
None, description="Transcript text for this segment"
)


class ResultEntry(BaseModel):
"""A single analysis result (one label with its score)."""
label: str = Field(..., description="Predicted label in lowercase")
score: float = Field(
..., ge=0.0, le=1.0, description="Confidence or proportion score"
)
intensity: Optional[Literal["low", "medium", "high"]] = Field(
None, description="Optional intensity qualifier"
)
segment: Optional[Segment] = Field(
None,
description="Temporal/textual segment info, null if result covers "
"the full input",
)


class StandardizedOutput(BaseModel):
"""Top-level standardized output for any sentiment or emotion analysis."""
schema_version: str = Field("1.0", description="Schema format version")
analysis_type: Literal["sentiment", "emotion"] = Field(
..., description="Type of analysis performed"
)
modality: Literal["text", "facial", "audio"] = Field(
..., description="Input modality that was analyzed"
)
source_model: str = Field(
...,
description="Identifier of the model or service that produced "
"the result",
)
timestamp: str = Field(
..., description="ISO 8601 datetime of when the analysis was performed"
)
task_id: Optional[str] = Field(
None,
description="Optional identifier for a usability test task or session",
)
input_summary: str = Field(
..., description="Short description of the analyzed input"
)
results: List[ResultEntry] = Field(
..., description="List of individual result entries"
)
Empty file added tests/__init__.py
Empty file.
Loading