# Image Rotation Manager

## Interface Definition

**Interface Name**: `IImageRotationManager`

### Interface Methods

```python
class IImageRotationManager(ABC):
    @abstractmethod
    def rotate_image_360(self, image: np.ndarray, angle: float) -> np.ndarray:
        pass
    
    @abstractmethod
    def try_rotation_steps(self, flight_id: str, frame_id: int, image: np.ndarray, satellite_tile: np.ndarray, tile_bounds: TileBounds, timestamp: datetime, matcher: IImageMatcher) -> Optional[RotationResult]:
        """
        Performs rotation sweep.
        'matcher' is an injected dependency (usually F09) to avoid direct coupling.
        """
        pass
    
    @abstractmethod
    def calculate_precise_angle(self, homography: np.ndarray, initial_angle: float) -> float:
        pass
    
    @abstractmethod
    def get_current_heading(self, flight_id: str) -> Optional[float]:
        pass
    
    @abstractmethod
    def update_heading(self, flight_id: str, frame_id: int, heading: float, timestamp: datetime) -> bool:
        pass
    
    @abstractmethod
    def detect_sharp_turn(self, flight_id: str, new_heading: float) -> bool:
        pass
    
    @abstractmethod
    def requires_rotation_sweep(self, flight_id: str) -> bool:
        pass
    
    @abstractmethod
    def rotate_chunk_360(self, chunk_images: List[np.ndarray], angle: float) -> List[np.ndarray]:
        pass
    
    @abstractmethod
    def try_chunk_rotation_steps(self, chunk_images: List[np.ndarray], satellite_tile: np.ndarray, tile_bounds: TileBounds, matcher: IImageMatcher) -> Optional[RotationResult]:
        pass
```

## Component Description

### Responsibilities
- Image rotation utility.
- Heading tracking.
- Coordination of rotation sweeps.

### Decoupling Fix
- **Problem**: F06 previously depended directly on `F09 Metric Refinement`.
- **Fix**: Methods `try_rotation_steps` and `try_chunk_rotation_steps` now accept a `matcher` argument conforming to `IImageMatcher`.
- **IImageMatcher Interface**:
  ```python
  class IImageMatcher(ABC):
      def align_to_satellite(self, uav_image, satellite_tile, tile_bounds) -> AlignmentResult: pass
      def align_chunk_to_satellite(self, chunk_images, satellite_tile, tile_bounds) -> ChunkAlignmentResult: pass
  ```
- **Runtime**: F02.2 injects F09 instance when calling F06 methods.

### Scope
- Image rotation operations (pure rotation, no matching)
- UAV heading tracking and history
- Sharp turn detection
- Rotation sweep coordination (rotates images, delegates matching to F09 Metric Refinement)
- Precise angle calculation from homography (extracted from F09 results)
- **Chunk-level rotation (all images rotated by same angle)**

## API Methods

### `rotate_image_360(image: np.ndarray, angle: float) -> np.ndarray`

**Description**: Rotates an image by specified angle around center.

**Called By**:
- Internal (during rotation sweep)
- H07 Image Rotation Utils (may delegate to)

**Input**:
```python
image: np.ndarray  # Input image (H×W×3)
angle: float  # Rotation angle in degrees (0-360)
```

**Output**:
```python
np.ndarray  # Rotated image (same dimensions)
```

**Processing Details**:
- Rotation around image center
- Preserves image dimensions
- Fills borders with black or extrapolation

**Error Conditions**:
- None (always returns rotated image)

**Test Cases**:
1. **Rotate 90°**: Image rotated correctly
2. **Rotate 0°**: Image unchanged
3. **Rotate 180°**: Image inverted
4. **Rotate 45°**: Diagonal rotation

---

### `try_rotation_steps(flight_id: str, frame_id: int, image: np.ndarray, satellite_tile: np.ndarray, tile_bounds: TileBounds, timestamp: datetime, matcher: IImageMatcher) -> Optional[RotationResult]`

**Description**: Performs 30° rotation sweep, rotating image at each step and delegating matching to F09 Metric Refinement.

**Called By**:
- Internal (when requires_rotation_sweep() returns True)
- Main processing loop (first frame or sharp turn)

**Input**:
```python
flight_id: str
frame_id: int  # Frame identifier for heading persistence
image: np.ndarray  # UAV image
satellite_tile: np.ndarray  # Satellite reference tile
tile_bounds: TileBounds  # GPS bounds and GSD of satellite tile (passed to F09)
timestamp: datetime  # Timestamp for heading persistence
matcher: IImageMatcher  # Injected matcher (F09)
```

**About tile_bounds**: `TileBounds` contains the GPS bounding box of the satellite tile:
- `nw`, `ne`, `sw`, `se`: GPS coordinates of tile corners
- `center`: GPS coordinate of tile center
- `gsd`: Ground Sampling Distance (meters/pixel)

The caller (F02 Flight Processor) obtains tile_bounds by calling `F04.compute_tile_bounds(tile_coords)` before calling this method. F06 passes tile_bounds to F09.align_to_satellite() which uses it to convert pixel coordinates to GPS.

**Output**:
```python
RotationResult:
    matched: bool
    initial_angle: float  # Best matching step angle (0, 30, 60, ...)
    precise_angle: float  # Refined angle from homography
    confidence: float
    homography: np.ndarray
```

**Algorithm**:
```
For angle in [0°, 30°, 60°, 90°, 120°, 150°, 180°, 210°, 240°, 270°, 300°, 330°]:
    rotated_image = rotate_image_360(image, angle)
    result = matcher.align_to_satellite(rotated_image, satellite_tile, tile_bounds)
    if result.matched and result.confidence > threshold:
        precise_angle = calculate_precise_angle(result.homography, angle)
        update_heading(flight_id, frame_id, precise_angle, timestamp)
        return RotationResult(matched=True, initial_angle=angle, precise_angle=precise_angle, ...)
return None  # No match found
```

**Processing Flow**:
1. For each 30° step:
   - Rotate image via rotate_image_360()
   - Call matcher.align_to_satellite(rotated_image, satellite_tile, tile_bounds)
   - Check if match found
2. If match found:
   - Calculate precise angle from homography via calculate_precise_angle()
   - Update UAV heading via update_heading()
   - Return RotationResult
3. If no match:
   - Return None (triggers progressive search expansion)

**Error Conditions**:
- Returns `None`: No match found in any rotation
- This is expected behavior (leads to progressive search)

**Test Cases**:
1. **Match at 60°**: Finds match, returns result
2. **Match at 0°**: No rotation needed, finds match
3. **No match**: All 12 rotations tried, returns None
4. **Multiple matches**: Returns best confidence

---

### `calculate_precise_angle(homography: np.ndarray, initial_angle: float) -> float`

**Description**: Calculates precise rotation angle from homography matrix point shifts.

**Called By**:
- Internal (after LiteSAM match in rotation sweep)

**Input**:
```python
homography: np.ndarray  # 3×3 homography matrix from LiteSAM
initial_angle: float  # 30° step angle that matched
```

**Output**:
```python
float: Precise rotation angle (e.g., 62.3° refined from 60° step)
```

**Algorithm**:
1. Extract rotation component from homography
2. Calculate angle from rotation matrix
3. Refine initial_angle with delta from homography

**Uses**: H07 Image Rotation Utils for angle calculation

**Error Conditions**:
- Falls back to initial_angle if calculation fails

**Test Cases**:
1. **Refine 60°**: Returns 62.5° (small delta)
2. **Refine 0°**: Returns 3.2° (small rotation)
3. **Invalid homography**: Returns initial_angle

---

### `get_current_heading(flight_id: str) -> Optional[float]`

**Description**: Gets current UAV heading angle for a flight.

**Called By**:
- F06 Internal (to check if pre-rotation needed)
- Main processing loop (before LiteSAM)
- F11 Failure Recovery Coordinator (logging)

**Input**:
```python
flight_id: str
```

**Output**:
```python
Optional[float]: Heading angle in degrees (0-360), or None if not initialized
```

**Error Conditions**:
- Returns `None`: First frame, heading not yet determined

**Test Cases**:
1. **After first frame**: Returns heading angle
2. **Before first frame**: Returns None
3. **During flight**: Returns current heading

---

### `update_heading(flight_id: str, frame_id: int, heading: float, timestamp: datetime) -> bool`

**Description**: Updates UAV heading angle after successful match.

**Called By**:
- Internal (after rotation sweep match)
- Internal (after normal LiteSAM match with small rotation delta)

**Input**:
```python
flight_id: str
frame_id: int  # Frame identifier for database persistence
heading: float  # New heading angle (0-360)
timestamp: datetime  # Timestamp for database persistence
```

**Output**:
```python
bool: True if updated successfully
```

**Processing Flow**:
1. Normalize angle to 0-360 range
2. Add to heading history (last 10 headings)
3. Update current_heading for flight
4. Return True (caller F02 is responsible for persistence via F03)

**Note**: Heading persistence is the caller's responsibility (F02 Flight Processor calls F03.save_heading() after receiving the updated heading).

**Test Cases**:
1. **Update heading**: Sets new heading
2. **Angle normalization**: 370° → 10°
3. **History tracking**: Maintains last 10 headings

---

### `detect_sharp_turn(flight_id: str, new_heading: float) -> bool`

**Description**: Detects if UAV made a sharp turn (>45° heading change).

**Called By**:
- Internal (before deciding if rotation sweep needed)
- Main processing loop

**Input**:
```python
flight_id: str
new_heading: float  # Proposed new heading
```

**Output**:
```python
bool: True if sharp turn detected (>45° change)
```

**Algorithm**:
```python
current = get_current_heading(flight_id)
if current is None:
    return False
delta = abs(new_heading - current)
if delta > 180:  # Handle wraparound
    delta = 360 - delta
return delta > 45
```

**Test Cases**:
1. **Small turn**: 60° → 75° → False (15° delta)
2. **Sharp turn**: 60° → 120° → True (60° delta)
3. **Wraparound**: 350° → 20° → False (30° delta)
4. **180° turn**: 0° → 180° → True

---

### `requires_rotation_sweep(flight_id: str) -> bool`

**Description**: Determines if rotation sweep is needed for current frame.

**Called By**:
- Main processing loop (before each frame)
- F11 Failure Recovery Coordinator (after tracking loss)

**Input**:
```python
flight_id: str
```

**Output**:
```python
bool: True if rotation sweep required
```

**Conditions for sweep**:
1. **First frame**: heading not initialized
2. **Sharp turn detected**: >45° heading change from VO
3. **Tracking loss**: LiteSAM failed to match in previous frame
4. **User flag**: Manual trigger (rare)

**Test Cases**:
1. **First frame**: Returns True
2. **Second frame, no turn**: Returns False
3. **Sharp turn detected**: Returns True
4. **Tracking loss**: Returns True

---

### `rotate_chunk_360(chunk_images: List[np.ndarray], angle: float) -> List[np.ndarray]`

**Description**: Rotates all images in a chunk by the same angle.

**Called By**:
- Internal (during try_chunk_rotation_steps)
- F11 Failure Recovery Coordinator (chunk rotation sweeps)

**Input**:
```python
chunk_images: List[np.ndarray]  # 5-20 images from chunk
angle: float  # Rotation angle in degrees (0-360)
```

**Output**:
```python
List[np.ndarray]  # Rotated images (same dimensions)
```

**Processing Flow**:
1. For each image in chunk:
   - rotate_image_360(image, angle) → rotated_image
2. Return list of rotated images

**Performance**:
- Rotation time: ~20ms × N images
- For 10 images: ~200ms total

**Test Cases**:
1. **Rotate chunk**: All images rotated correctly
2. **Angle consistency**: All images rotated by same angle
3. **Image preservation**: Original images unchanged

---

### `try_chunk_rotation_steps(chunk_images: List[np.ndarray], satellite_tile: np.ndarray, tile_bounds: TileBounds, matcher: IImageMatcher) -> Optional[RotationResult]`

**Description**: Performs 30° rotation sweep on entire chunk, rotating all images at each step and delegating matching to F09 Metric Refinement.

**Called By**:
- F11 Failure Recovery Coordinator (chunk matching with rotation)

**Input**:
```python
chunk_images: List[np.ndarray]  # Chunk images
satellite_tile: np.ndarray  # Reference satellite tile
tile_bounds: TileBounds  # GPS bounds and GSD of satellite tile (for F09)
matcher: IImageMatcher # Injected matcher
```

**Output**:
```python
RotationResult:
    matched: bool
    initial_angle: float  # Best matching step angle (0, 30, 60, ...)
    precise_angle: float  # Refined angle from homography
    confidence: float
    homography: np.ndarray
```

**Algorithm**:
```
For angle in [0°, 30°, 60°, 90°, 120°, 150°, 180°, 210°, 240°, 270°, 300°, 330°]:
    rotated_chunk = rotate_chunk_360(chunk_images, angle)
    result = matcher.align_chunk_to_satellite(rotated_chunk, satellite_tile, tile_bounds)
    if result.matched and result.confidence > threshold:
        precise_angle = calculate_precise_angle(result.homography, angle)
        return RotationResult(matched=True, initial_angle=angle, precise_angle=precise_angle, ...)
return None  # No match found
```

**Processing Flow**:
1. For each 30° step:
   - Rotate all chunk images via rotate_chunk_360()
   - Call matcher.align_chunk_to_satellite(rotated_chunk, satellite_tile, tile_bounds)
   - Check if match found
2. If match found:
   - Calculate precise angle from homography via calculate_precise_angle()
   - Return RotationResult
3. If no match:
   - Return None

**Performance**:
- 12 rotations × chunk matching via F09 (~60ms) = ~720ms
- Acceptable for chunk matching (async operation)

**Test Cases**:
1. **Match at 60°**: Finds match, returns result
2. **Match at 0°**: No rotation needed, finds match
3. **No match**: All 12 rotations tried, returns None
4. **Multiple matches**: Returns best confidence

## Integration Tests

### Test 1: First Frame Rotation Sweep
1. First frame arrives (no heading set)
2. requires_rotation_sweep() → True
3. try_rotation_steps(flight_id, frame_id=1, image, satellite_tile, tile_bounds, timestamp=now()) → rotates 12 times
4. F09 Metric Refinement called for each rotation
5. Match found at 60° step
6. calculate_precise_angle() → 62.3°
7. update_heading(flight_id, frame_id=1, heading=62.3°, timestamp=now())
8. Subsequent frames use 62.3° heading

### Test 2: Normal Frame Processing
1. Heading known (90°)
2. requires_rotation_sweep() → False
3. Pre-rotate image to 90°
4. LiteSAM match succeeds with small delta (+2.5°)
5. update_heading(flight_id, frame_id=237, heading=92.5°, timestamp=now())

### Test 3: Sharp Turn Detection
1. UAV heading 45°
2. Next frame shows 120° heading (from VO estimate)
3. detect_sharp_turn() → True (75° delta)
4. requires_rotation_sweep() → True
5. Perform rotation sweep → find match at 120° step

### Test 4: Tracking Loss Recovery
1. F09 Metric Refinement fails to match (no overlap after turn)
2. requires_rotation_sweep() → True
3. try_rotation_steps(flight_id, frame_id, image, satellite_tile, tile_bounds, timestamp) with all 12 rotations
4. F09 called for each rotation step
5. Match found → heading updated

### Test 5: Chunk Rotation Sweeps
1. Build chunk with 10 images (unknown orientation)
2. try_chunk_rotation_steps(chunk_images, satellite_tile, tile_bounds) with all 12 rotations
3. F09 Metric Refinement called for each rotation
4. Match found at 120° step
5. Precise angle calculated (122.5°)
6. Verify all images rotated consistently

## Non-Functional Requirements

### Performance
- **rotate_image_360**: < 20ms per rotation
- **try_rotation_steps**: < 1.2 seconds (12 rotations × 100ms LiteSAM)
- **calculate_precise_angle**: < 10ms
- **get_current_heading**: < 1ms
- **update_heading**: < 5ms

### Accuracy
- **Angle precision**: ±0.5° for precise angle calculation
- **Sharp turn detection**: 100% accuracy for >45° turns

### Reliability
- Rotation sweep always completes all 12 steps
- Graceful handling of no-match scenarios
- Heading history preserved across failures

## Dependencies

### Internal Components
- **H07 Image Rotation Utils**: For image rotation and angle calculations
- **Injected Matcher (F09)**.

**Note**: 
- `TileBounds` data model is imported from F09 Metric Refinement.
- F06 does NOT call F04 directly. The caller (F02 or F11) provides satellite tiles and tile_bounds.
- F06 does NOT persist heading to database. The caller (F02) is responsible for calling F03.save_heading().
- Chunk rotation orchestration (calling try_chunk_rotation_steps in recovery flow) is done by F11 Failure Recovery Coordinator.

### External Dependencies
- **opencv-python**: Image rotation (`cv2.warpAffine`)
- **numpy**: Matrix operations

## Data Models

### RotationResult
```python
class RotationResult(BaseModel):
    matched: bool
    initial_angle: float  # 30° step angle (0, 30, 60, ...)
    precise_angle: float  # Refined angle from homography
    confidence: float
    homography: np.ndarray
    inlier_count: int
```

### HeadingHistory
```python
class HeadingHistory(BaseModel):
    flight_id: str
    current_heading: float
    heading_history: List[float]  # Last 10 headings
    last_update: datetime
    sharp_turns: int  # Count of sharp turns detected
```

### RotationConfig
```python
class RotationConfig(BaseModel):
    step_angle: float = 30.0  # Degrees
    sharp_turn_threshold: float = 45.0  # Degrees
    confidence_threshold: float = 0.7  # For accepting match
    history_size: int = 10  # Number of headings to track
```
