File: /home/panomity.de/vr.panomity.com/api/gpuq-pois-exec.php
<?php
/**
* GPUQ POI-Exec: wird vom GPUQ-Worker aufgerufen (job_type vr_pois).
*
* Erkennt markante Objekte/Merkmale einer Szene per Vision-Modell
* (mit Pixel-Grounding) und speichert sie als POIs mit Kugelkoordinaten.
*
* POST { _gpuq_secret, pano_id }
*/
header('Content-Type: application/json; charset=utf-8');
set_time_limit(600);
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['ok' => false, 'message' => 'Method not allowed']);
exit;
}
$config = require __DIR__ . '/config.gpuq.php';
$raw = file_get_contents('php://input') ?: '';
$body = json_decode($raw, true);
if (!is_array($body)) {
http_response_code(400);
echo json_encode(['ok' => false, 'message' => 'Invalid JSON payload']);
exit;
}
$secret = (string)($body['_gpuq_secret'] ?? '');
if ($secret === '' || !hash_equals((string)($config['self_exec_secret'] ?? ''), $secret)) {
http_response_code(403);
echo json_encode(['ok' => false, 'message' => 'Forbidden']);
exit;
}
$panoId = (int)($body['pano_id'] ?? 0);
if ($panoId <= 0) {
http_response_code(400);
echo json_encode(['ok' => false, 'message' => 'Invalid pano_id']);
exit;
}
$db = new SQLite3(__DIR__ . '/../admin/data/vrm.db');
$db->busyTimeout(10000);
$db->exec('CREATE TABLE IF NOT EXISTS pano_pois (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pano_id INTEGER NOT NULL,
label_en TEXT, label_de TEXT,
desc_en TEXT, desc_de TEXT,
ath REAL NOT NULL, atv REAL NOT NULL,
active INTEGER DEFAULT 1,
created_at TEXT
)');
require_once __DIR__ . '/ollama_context_helper.php';
// Equirect laden und für das Vision-Modell verkleinern.
$baseDir = __DIR__ . '/../panos/p' . $panoId . '.tiles/';
$imagePath = is_file($baseDir . 'equirect.jpg') ? $baseDir . 'equirect.jpg' : null;
if ($imagePath === null) {
http_response_code(404);
echo json_encode(['ok' => false, 'message' => 'equirect.jpg not found']);
exit;
}
$maxWidth = 1600;
$src = @imagecreatefromjpeg($imagePath);
if ($src === false) {
http_response_code(500);
echo json_encode(['ok' => false, 'message' => 'Failed to read equirect']);
exit;
}
$w = imagesx($src);
$h = imagesy($src);
$nw = min($w, $maxWidth);
$nh = (int)round($h * $nw / $w);
if ($nw !== $w) {
$dst = imagecreatetruecolor($nw, $nh);
imagecopyresampled($dst, $src, 0, 0, 0, 0, $nw, $nh, $w, $h);
imagedestroy($src);
$src = $dst;
}
ob_start();
imagejpeg($src, null, 88);
$jpeg = ob_get_clean();
imagedestroy($src);
$imageBase64 = base64_encode($jpeg);
$prompt =
"This is a 360-degree equirectangular panorama of one room in a condominium (image size {$nw}x{$nh} pixels).\n" .
"Identify 3 to 6 notable, clearly visible features a home buyer would care about " .
"(e.g. appliances, fireplace, built-in storage, windows with views, fixtures, flooring transitions).\n\n" .
"Output ONLY a JSON array, no markdown. Each item:\n" .
'{"label_en":"...","label_de":"...","desc_en":"...","desc_de":"...","cx":<int>,"cy":<int>}' . "\n\n" .
"Rules:\n" .
"- label: 1-3 words. desc: one factual, appealing sentence (max 18 words). German idiomatic.\n" .
"- cx,cy = pixel coordinates of the feature's center in this image.\n" .
"- Only features you can clearly see. No duplicates, no generic walls/ceiling.";
$ollamaUrl = rtrim((string)($config['ollama_url'] ?? 'http://127.0.0.1:11434'), '/');
$model = (string)($config['vision_model'] ?? 'qwen2.5vl:7b');
// Genug Token für 6 POIs in zwei Sprachen; verhindert abgeschnittenes JSON.
$try = call_ollama_vision_chat($ollamaUrl, $model, $prompt, $imageBase64, false, ['num_predict' => 1024]);
if ($try['http'] !== 200 || !empty($try['err'])) {
http_response_code(502);
echo json_encode(['ok' => false, 'message' => 'Vision model error', 'details' => (string)$try['err']]);
exit;
}
$response = json_decode((string)$try['body'], true);
$content = (string)($response['message']['content'] ?? '');
// Zuerst versuchen, ein komplettes JSON-Array zu parsen; falls das Modell
// die Ausgabe abgeschnitten hat (Token-Limit), einzelne {…}-Objekte einsammeln.
$pois = null;
if (preg_match('/\[[\s\S]*\]/', $content, $m)) {
$pois = json_decode($m[0], true);
}
if (!is_array($pois)) {
$pois = [];
// Balancierte Top-Level-Objekte einzeln extrahieren (auch bei Trunkierung).
$len = strlen($content);
$depth = 0;
$start = -1;
for ($i = 0; $i < $len; $i++) {
$ch = $content[$i];
if ($ch === '{') {
if ($depth === 0) {
$start = $i;
}
$depth++;
} elseif ($ch === '}') {
if ($depth > 0) {
$depth--;
if ($depth === 0 && $start >= 0) {
$obj = json_decode(substr($content, $start, $i - $start + 1), true);
if (is_array($obj)) {
$pois[] = $obj;
}
$start = -1;
}
}
}
}
}
if (!is_array($pois) || count($pois) === 0) {
http_response_code(502);
echo json_encode(['ok' => false, 'message' => 'Unparseable JSON from model', 'raw' => mb_substr($content, 0, 400)]);
exit;
}
// Alte POIs der Szene ersetzen.
$stmt = $db->prepare('DELETE FROM pano_pois WHERE pano_id = :id');
$stmt->bindValue(':id', $panoId, SQLITE3_INTEGER);
$stmt->execute();
$saved = [];
foreach ($pois as $poi) {
if (!is_array($poi)) {
continue;
}
$labelEn = trim(mb_substr((string)($poi['label_en'] ?? ''), 0, 60));
$labelDe = trim(mb_substr((string)($poi['label_de'] ?? ''), 0, 60));
$cx = (float)($poi['cx'] ?? -1);
$cy = (float)($poi['cy'] ?? -1);
if ($labelEn === '' || $cx < 0 || $cy < 0 || $cx > $nw || $cy > $nh) {
continue;
}
// Pixel → Kugelkoordinaten (Equirect: Bildmitte = ath 0 / atv 0)
$ath = ($cx / $nw - 0.5) * 360.0;
$atv = ($cy / $nh - 0.5) * 180.0;
// Extreme Zenit/Nadir-Werte sind meist Fehldetektionen.
if (abs($atv) > 75) {
continue;
}
$stmt = $db->prepare('INSERT INTO pano_pois (pano_id, label_en, label_de, desc_en, desc_de, ath, atv, active, created_at)
VALUES (:pid, :len, :lde, :den, :dde, :ath, :atv, 1, :now)');
$stmt->bindValue(':pid', $panoId, SQLITE3_INTEGER);
$stmt->bindValue(':len', $labelEn, SQLITE3_TEXT);
$stmt->bindValue(':lde', $labelDe !== '' ? $labelDe : $labelEn, SQLITE3_TEXT);
$stmt->bindValue(':den', trim(mb_substr((string)($poi['desc_en'] ?? ''), 0, 300)), SQLITE3_TEXT);
$stmt->bindValue(':dde', trim(mb_substr((string)($poi['desc_de'] ?? ''), 0, 300)), SQLITE3_TEXT);
$stmt->bindValue(':ath', round($ath, 2));
$stmt->bindValue(':atv', round($atv, 2));
$stmt->bindValue(':now', gmdate('c'), SQLITE3_TEXT);
$stmt->execute();
$saved[] = ['label' => $labelEn, 'ath' => round($ath, 1), 'atv' => round($atv, 1)];
}
$db->close();
echo json_encode(['ok' => true, 'pano_id' => $panoId, 'pois' => $saved], JSON_UNESCAPED_UNICODE);