HEX
Server: LiteSpeed
System: Linux houston.panomity.com 6.8.0-100-generic #100-Ubuntu SMP PREEMPT_DYNAMIC Tue Jan 13 16:40:06 UTC 2026 x86_64
User: nudepix (1011)
PHP: 7.4.33
Disabled: pcntl_alarm,pcntl_fork,pcntl_waitpid,pcntl_wait,pcntl_wifexited,pcntl_wifstopped,pcntl_wifsignaled,pcntl_wifcontinued,pcntl_wexitstatus,pcntl_wtermsig,pcntl_wstopsig,pcntl_signal,pcntl_signal_get_handler,pcntl_signal_dispatch,pcntl_get_last_error,pcntl_strerror,pcntl_sigprocmask,pcntl_sigwaitinfo,pcntl_sigtimedwait,pcntl_exec,pcntl_getpriority,pcntl_setpriority,pcntl_async_signals,pcntl_unshare,
Upload Files
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);