FloorVisualizer/clip_server/server.py
2026-07-27 16:11:15 +08:00

91 lines
3.4 KiB
Python

"""CLIP-based indoor room classifier. Runs ViT-B/32 on CPU, ~100ms per image."""
import io
import logging
import os
import open_clip
import torch
from PIL import Image
from flask import Flask, jsonify, request
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("clip-server")
app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 50 * 1024 * 1024 # 50 MB max upload
# Load once at startup
MODEL_NAME = os.getenv("CLIP_MODEL", "ViT-B-32")
PRETRAINED = os.getenv("CLIP_PRETRAINED", "laion2b_s34b_b79k")
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
log.info(f"Loading CLIP model: {MODEL_NAME} pretrained={PRETRAINED} device={DEVICE}")
model, _, preprocess = open_clip.create_model_and_transforms(MODEL_NAME, pretrained=PRETRAINED)
model = model.to(DEVICE).eval()
tokenizer = open_clip.get_tokenizer(MODEL_NAME)
# Text prompts for indoor/outdoor classification
PROMPTS = [
"a photo of an indoor room interior with floor visible",
"a photo of an outdoor scene, landscape, street, or non-room image",
]
@app.route("/health", methods=["GET"])
def health():
return jsonify({"status": "ok", "model": MODEL_NAME, "device": DEVICE})
@app.route("/check", methods=["POST"])
def check():
"""Classify whether an image is an indoor room photo suitable for floor replacement.
Expects raw image bytes as the request body. Returns {score: 0-10, passed: bool}."""
if not request.data:
return jsonify({"error": "no image data"}), 400
try:
img = Image.open(io.BytesIO(request.data)).convert("RGB")
except Exception as e:
return jsonify({"error": f"bad image: {e}"}), 400
# Multiple prompt pairs for robust ensemble (indoor vs outdoor)
prompt_pairs = [
("a photo of an indoor room interior with floor", "a photo of an outdoor scene"),
("interior room photograph, real estate photo", "exterior photograph, outdoor photo"),
("an indoor space with visible flooring", "an outdoor space, landscape, street view"),
]
image_input = preprocess(img).unsqueeze(0).to(DEVICE)
wins = 0
gap_sum = 0.0
for indoor_text, outdoor_text in prompt_pairs:
text_input = tokenizer([indoor_text, outdoor_text]).to(DEVICE)
with torch.no_grad():
image_features = model.encode_image(image_input)
text_features = model.encode_text(text_input)
image_features /= image_features.norm(dim=-1, keepdim=True)
text_features /= text_features.norm(dim=-1, keepdim=True)
raw = (image_features @ text_features.T)[0] # cosine similarities
indoor_sim = float(raw[0].item())
outdoor_sim = float(raw[1].item())
if indoor_sim > outdoor_sim:
wins += 1
gap_sum += indoor_sim - outdoor_sim # positive = indoor wins
indoor_wins = wins >= 2 # majority vote (2 out of 3)
avg_gap = gap_sum / len(prompt_pairs)
# Map avg_gap to 1-10: gap ~0.05 = score 6, gap ~0.15+ = score 10
score_1_10 = max(1, min(10, int(5 + avg_gap * 40)))
passed = indoor_wins
log.info(
f"wins={wins}/3 avg_gap={avg_gap:.3f} score_1_10={score_1_10} passed={passed}"
)
return jsonify({"score": score_1_10, "passed": passed})
if __name__ == "__main__":
port = int(os.getenv("CLIP_PORT", "5100"))
log.info(f"CLIP server listening on :{port}")
app.run(host="0.0.0.0", port=port, debug=False)