File size: 7,577 Bytes
3de34d4
 
47efa14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3de34d4
 
 
 
47efa14
3de34d4
47efa14
3de34d4
 
 
 
47efa14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3de34d4
 
 
47efa14
 
 
 
 
 
 
 
 
 
 
 
 
 
3de34d4
 
47efa14
 
 
 
 
 
 
 
3de34d4
 
47efa14
 
 
 
 
 
 
 
aeea5fe
 
 
 
 
 
 
 
 
 
 
 
47efa14
 
 
 
aeea5fe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47efa14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aeea5fe
47efa14
 
 
 
 
 
 
 
 
 
 
 
 
 
3de34d4
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
"""Lightweight indoor/outdoor classifier for routing PaGeR's twin scale heads.

Wraps CLIP ViT-B/32 (OpenAI weights via ``open_clip``) behind a tiny
``classify(cubemap) -> ("indoor"|"outdoor", p_outdoor)`` API. The classifier
reuses the 4 equatorial faces of the cubemap that PaGeR's depth pipeline
already computes from the input panorama (Front, Right, Back, Left at indices
0-3 of the standard 'stack' face order), so it adds no extra projection cost.

Each of the 4 faces is resized to 224×224, normalised with CLIP's image mean
/ std, and run through CLIP's image encoder. Indoor / outdoor are represented
as L2-normalised mean text-embedding centroids over a small fixed prompt
ensemble; per-face cosine similarities are softmax'd with CLIP's 100× logit
scale and averaged across faces to give ``P(outdoor)``.

The default prompt set ('rich') was validated across the three evaluation
datasets shipped with PaGeR (Stanford2D3DS, Matterport3D360, ZuriPano) and
gives universally good routing without per-dataset tuning.
"""

from __future__ import annotations

from typing import Sequence, Union

import open_clip
import torch
import torch.nn.functional as F


# Rich prompt ensemble — verified universal across PaGeR's eval datasets.
DEFAULT_INDOOR_PROMPTS: tuple[str, ...] = (
    "an indoor scene",
    "the interior of a building",
    "a room inside a building",
    "a hallway",
    "an office room",
    "a bedroom",
    "a living room",
    "a kitchen",
    "an indoor space with walls and a ceiling",
)
DEFAULT_OUTDOOR_PROMPTS: tuple[str, ...] = (
    "an outdoor scene",
    "the outdoors",
    "outside in nature",
    "a street view",
    "a city street",
    "an urban panorama",
    "a park",
    "a landscape",
    "a residential neighborhood",
)

# CLIP image-encoder normalisation (different from ImageNet).
_CLIP_MEAN = (0.48145466, 0.4578275, 0.40821073)
_CLIP_STD = (0.26862954, 0.26130258, 0.27577711)


class IndoorOutdoorClassifier:
    """CLIP ViT-B/32 indoor/outdoor scene classifier operating on cubemap faces.

    Parameters
    ----------
    device:
        Torch device the CLIP model lives on. Inputs are moved to this device
        inside :meth:`classify`.
    indoor_prompts, outdoor_prompts:
        Text prompts used to build the two class centroids. Each centroid is
        the L2-normalised mean of its prompts' L2-normalised CLIP embeddings.
    model_name, pretrained:
        Passed to :func:`open_clip.create_model_and_transforms`. The defaults
        (``"ViT-B-32"`` / ``"openai"``) give a 151M-param image+text model
        with the image encoder running at ~10 ms / sample on a recent GPU.
    """

    def __init__(
        self,
        device: Union[str, torch.device] = "cuda",
        indoor_prompts: Sequence[str] = DEFAULT_INDOOR_PROMPTS,
        outdoor_prompts: Sequence[str] = DEFAULT_OUTDOOR_PROMPTS,
        model_name: str = "ViT-B-32",
        pretrained: str = "openai",
    ) -> None:
        self.device = torch.device(device) if not isinstance(device, torch.device) else device

        # ``force_quick_gelu`` matches the activation that OpenAI's CLIP was
        # trained with; without it open_clip emits a warning and silently
        # falls back to standard GELU, which gives slightly different logits.
        model, _, _ = open_clip.create_model_and_transforms(
            model_name, pretrained=pretrained, device=self.device,
            force_quick_gelu=(pretrained == "openai"),
        )
        self.model = model.eval()
        self.tokenizer = open_clip.get_tokenizer(model_name)

        # The text centroids are built lazily on first ``classify`` call -- see
        # ``_ensure_centroids``. Building them here would run a CLIP text-encoder
        # forward pass at construction time, which breaks on HF ZeroGPU: a GPU
        # is only attached inside a ``@spaces.GPU`` window, so a forward run at
        # import/startup has no device. Loading the weights (above) is fine;
        # only the forward must wait for a GPU window.
        self._indoor_prompts = tuple(indoor_prompts)
        self._outdoor_prompts = tuple(outdoor_prompts)
        self.text_indoor = None    # (1, D), filled lazily
        self.text_outdoor = None   # (1, D), filled lazily

        # Image-side normalisation buffers (kept on device for fast inference).
        self.register_clip_norm(self.device)

    @torch.inference_mode()
    def _ensure_centroids(self) -> None:
        """Build the indoor/outdoor text centroids on first use (idempotent).

        Each centroid is the L2-normalised mean of its prompts' L2-normalised
        CLIP text embeddings. Deferred out of ``__init__`` so the forward pass
        runs inside the caller's GPU window (see the note there)."""
        if self.text_indoor is not None:
            return
        centroids = {}
        for key, prompts in (("indoor", self._indoor_prompts),
                             ("outdoor", self._outdoor_prompts)):
            toks = self.tokenizer(list(prompts)).to(self.device)
            feats = F.normalize(self.model.encode_text(toks), dim=-1)
            centroids[key] = F.normalize(feats.mean(dim=0, keepdim=True), dim=-1)
        self.text_indoor = centroids["indoor"]    # (1, D)
        self.text_outdoor = centroids["outdoor"]  # (1, D)

    def register_clip_norm(self, device: torch.device) -> None:
        self._clip_mean = torch.tensor(_CLIP_MEAN, device=device).view(1, 3, 1, 1)
        self._clip_std = torch.tensor(_CLIP_STD, device=device).view(1, 3, 1, 1)

    @torch.inference_mode()
    def p_outdoor(self, cubemap_01: torch.Tensor) -> float:
        """Return P(outdoor) for one panorama.

        Parameters
        ----------
        cubemap_01:
            Cubemap tensor of shape ``(6, 3, F, F)`` or ``(1, 6, 3, F, F)``
            with raw RGB in ``[0, 1]`` (no normalisation). Only faces 0-3
            (Front, Right, Back, Left from the standard 'stack' order) are
            used; the top / bottom faces are ignored.
        """
        if cubemap_01.ndim == 5:
            cubemap_01 = cubemap_01[0]
        if cubemap_01.ndim != 4 or cubemap_01.shape[0] < 4:
            raise ValueError(
                f"Expected cubemap of shape (6, 3, F, F); got {tuple(cubemap_01.shape)}"
            )
        self._ensure_centroids()
        eq = cubemap_01[:4].to(self.device).clamp(0, 1)
        eq = F.interpolate(eq, size=(224, 224), mode="bilinear",
                           align_corners=False, antialias=True)
        eq = (eq - self._clip_mean) / self._clip_std
        feats = F.normalize(self.model.encode_image(eq), dim=-1)         # (4, D)
        s_in = (feats @ self.text_indoor.T).squeeze(-1)                  # (4,)
        s_out = (feats @ self.text_outdoor.T).squeeze(-1)                # (4,)
        probs = torch.stack([s_in, s_out], dim=-1).mul(100.0).softmax(dim=-1)
        return float(probs[:, 1].mean().item())

    def classify(self, cubemap_01: torch.Tensor, threshold: float = 0.5):
        """Return ``(label, p_outdoor)``; ``label`` is ``"indoor"`` if
        ``p_outdoor < threshold`` else ``"outdoor"``."""
        p = self.p_outdoor(cubemap_01)
        return ("outdoor" if p >= threshold else "indoor", p)


_SINGLETON: "IndoorOutdoorClassifier | None" = None


def get_classifier(device: Union[str, torch.device] = "cuda") -> IndoorOutdoorClassifier:
    """Return a process-wide singleton classifier (loaded on first call)."""
    global _SINGLETON
    if _SINGLETON is None:
        _SINGLETON = IndoorOutdoorClassifier(device=device)
    return _SINGLETON