- Theme: momentry (custom theme with REST API routes) - Plugins: code-snippets (contains all API proxies) - Languages: zh_TW translations - Excludes: cache, backups, uploads, logs
382 lines
13 KiB
PHP
382 lines
13 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace Momentry;
|
|
|
|
use WP_REST_Request;
|
|
use WP_REST_Response;
|
|
use WP_Error;
|
|
use PDO;
|
|
|
|
defined('ABSPATH') || exit;
|
|
|
|
class Identity_API {
|
|
private PDO $db;
|
|
private string $schema;
|
|
|
|
public function __construct() {
|
|
$this->db = Database::get_instance();
|
|
$this->schema = Database::get_schema();
|
|
}
|
|
|
|
public function register_routes(): void {
|
|
register_rest_route('momentry/v1', '/identities', [
|
|
'methods' => 'GET',
|
|
'callback' => [$this, 'get_identities'],
|
|
'permission_callback' => [$this, 'check_permission'],
|
|
]);
|
|
|
|
register_rest_route('momentry/v1', '/identities/(?P<uuid>[a-f0-9\-]{36})', [
|
|
'methods' => 'GET',
|
|
'callback' => [$this, 'get_identity_detail'],
|
|
'permission_callback' => [$this, 'check_permission'],
|
|
]);
|
|
|
|
register_rest_route('momentry/v1', '/identities/(?P<uuid>[a-f0-9\-]{36})/angle-coverage', [
|
|
'methods' => 'GET',
|
|
'callback' => [$this, 'get_angle_coverage'],
|
|
'permission_callback' => [$this, 'check_permission'],
|
|
]);
|
|
|
|
register_rest_route('momentry/v1', '/identities/(?P<uuid>[a-f0-9\-]{36})/body-actions', [
|
|
'methods' => 'GET',
|
|
'callback' => [$this, 'get_body_actions'],
|
|
'permission_callback' => [$this, 'check_permission'],
|
|
]);
|
|
|
|
register_rest_route('momentry/v1', '/identities/(?P<uuid>[a-f0-9\-]{36})/reference-vectors', [
|
|
'methods' => 'GET',
|
|
'callback' => [$this, 'get_reference_vectors'],
|
|
'permission_callback' => [$this, 'check_permission'],
|
|
]);
|
|
}
|
|
|
|
public function check_permission(): bool {
|
|
return current_user_can('read') || $this->validate_api_key();
|
|
}
|
|
|
|
private function validate_api_key(): bool {
|
|
$api_key = $_SERVER['HTTP_X_MOMENTRY_API_KEY'] ?? '';
|
|
$valid_key = getenv('MOMENTRY_API_KEY');
|
|
|
|
if (!$valid_key) {
|
|
return false;
|
|
}
|
|
|
|
return hash_equals($valid_key, $api_key);
|
|
}
|
|
|
|
public function get_identities(WP_REST_Request $request): WP_REST_Response {
|
|
$page = (int) $request->get_param('page') ?: 1;
|
|
$per_page = (int) $request->get_param('per_page') ?: 50;
|
|
$offset = ($page - 1) * $per_page;
|
|
$search = $request->get_param('search');
|
|
$source = $request->get_param('source');
|
|
|
|
$sql = "SELECT
|
|
uuid,
|
|
name,
|
|
identity_type,
|
|
source,
|
|
tmdb_id,
|
|
created_at,
|
|
reference_data->>'total_references' as total_references,
|
|
reference_data->>'quality_avg' as quality_avg,
|
|
reference_data->'trace_stats'->>'duration_seconds' as trace_duration,
|
|
reference_data->'trace_stats'->>'avg_confidence' as trace_confidence,
|
|
reference_data->'trace_stats'->>'total_appearances' as trace_appearances
|
|
FROM {$this->schema}.identities";
|
|
|
|
$where = [];
|
|
$params = [];
|
|
|
|
if ($search) {
|
|
$where[] = "name ILIKE ?";
|
|
$params[] = "%{$search}%";
|
|
}
|
|
|
|
if ($source) {
|
|
$where[] = "source = ?";
|
|
$params[] = $source;
|
|
}
|
|
|
|
if (!empty($where)) {
|
|
$sql .= " WHERE " . implode(' AND ', $where);
|
|
}
|
|
|
|
$sql .= " ORDER BY created_at DESC LIMIT ? OFFSET ?";
|
|
$params[] = $per_page;
|
|
$params[] = $offset;
|
|
|
|
$stmt = $this->db->prepare($sql);
|
|
$stmt->execute($params);
|
|
$identities = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
|
|
|
$count_sql = "SELECT COUNT(*) FROM {$this->schema}.identities";
|
|
if (!empty($where)) {
|
|
$count_sql .= " WHERE " . implode(' AND ', $where);
|
|
}
|
|
$stmt = $this->db->prepare($count_sql);
|
|
$stmt->execute(array_slice($params, 0, -2));
|
|
$total = (int) $stmt->fetchColumn();
|
|
|
|
$identities = array_map(function ($identity) {
|
|
return $this->format_identity_list_item($identity);
|
|
}, $identities);
|
|
|
|
return new WP_REST_Response([
|
|
'success' => true,
|
|
'data' => $identities,
|
|
'pagination' => [
|
|
'page' => $page,
|
|
'per_page' => $per_page,
|
|
'total' => $total,
|
|
'total_pages' => ceil($total / $per_page),
|
|
],
|
|
]);
|
|
}
|
|
|
|
public function get_identity_detail(WP_REST_Request $request): WP_REST_Response|WP_Error {
|
|
$uuid = $request['uuid'];
|
|
|
|
$sql = "SELECT
|
|
uuid,
|
|
name,
|
|
identity_type,
|
|
source,
|
|
tmdb_id,
|
|
tmdb_profile,
|
|
reference_data,
|
|
created_at,
|
|
updated_at
|
|
FROM {$this->schema}.identities
|
|
WHERE uuid = ?";
|
|
|
|
$stmt = $this->db->prepare($sql);
|
|
$stmt->execute([$uuid]);
|
|
$identity = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
|
|
if (!$identity) {
|
|
return new WP_Error(
|
|
'not_found',
|
|
'Identity not found',
|
|
['status' => 404]
|
|
);
|
|
}
|
|
|
|
return new WP_REST_Response([
|
|
'success' => true,
|
|
'data' => $this->format_identity_detail($identity),
|
|
]);
|
|
}
|
|
|
|
public function get_angle_coverage(WP_REST_Request $request): WP_REST_Response|WP_Error {
|
|
$uuid = $request['uuid'];
|
|
|
|
$sql = "SELECT reference_data FROM {$this->schema}.identities WHERE uuid = ?";
|
|
$stmt = $this->db->prepare($sql);
|
|
$stmt->execute([$uuid]);
|
|
$result = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
|
|
if (!$result) {
|
|
return new WP_Error(
|
|
'not_found',
|
|
'Identity not found',
|
|
['status' => 404]
|
|
);
|
|
}
|
|
|
|
$reference_data = json_decode($result['reference_data'], true);
|
|
|
|
$angle_coverage = $this->calculate_angle_coverage($reference_data);
|
|
|
|
return new WP_REST_Response([
|
|
'success' => true,
|
|
'data' => [
|
|
'uuid' => $uuid,
|
|
'angles' => $angle_coverage['angles'],
|
|
'coverage_score' => $angle_coverage['score'],
|
|
'recommendation' => $angle_coverage['recommendation'],
|
|
],
|
|
]);
|
|
}
|
|
|
|
public function get_body_actions(WP_REST_Request $request): WP_REST_Response|WP_Error {
|
|
$uuid = $request['uuid'];
|
|
|
|
$sql = "SELECT
|
|
i.uuid,
|
|
i.name,
|
|
i.reference_data
|
|
FROM {$this->schema}.identities i
|
|
WHERE i.uuid = ?";
|
|
|
|
$stmt = $this->db->prepare($sql);
|
|
$stmt->execute([$uuid]);
|
|
$identity = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
|
|
if (!$identity) {
|
|
return new WP_Error(
|
|
'not_found',
|
|
'Identity not found',
|
|
['status' => 404]
|
|
);
|
|
}
|
|
|
|
$reference_data = json_decode($identity['reference_data'], true);
|
|
$body_actions = $reference_data['body_actions'] ?? [];
|
|
$action_statistics = $reference_data['action_statistics'] ?? [];
|
|
|
|
return new WP_REST_Response([
|
|
'success' => true,
|
|
'data' => [
|
|
'uuid' => $uuid,
|
|
'name' => $identity['name'],
|
|
'actions' => $body_actions,
|
|
'statistics' => $action_statistics,
|
|
],
|
|
]);
|
|
}
|
|
|
|
public function get_reference_vectors(WP_REST_Request $request): WP_REST_Response|WP_Error {
|
|
$uuid = $request['uuid'];
|
|
|
|
$sql = "SELECT reference_data FROM {$this->schema}.identities WHERE uuid = ?";
|
|
$stmt = $this->db->prepare($sql);
|
|
$stmt->execute([$uuid]);
|
|
$result = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
|
|
if (!$result) {
|
|
return new WP_Error(
|
|
'not_found',
|
|
'Identity not found',
|
|
['status' => 404]
|
|
);
|
|
}
|
|
|
|
$reference_data = json_decode($result['reference_data'], true);
|
|
$face_embeddings = $reference_data['face_embeddings'] ?? [];
|
|
|
|
$vectors = array_map(function ($embedding) {
|
|
return [
|
|
'angle' => $embedding['angle'] ?? 'unknown',
|
|
'frame' => $embedding['frame'] ?? null,
|
|
'quality_score' => $embedding['quality_score'] ?? 0,
|
|
'pitch' => $embedding['pitch'] ?? 'neutral',
|
|
'pose_confidence' => $embedding['pose_confidence'] ?? 0,
|
|
'detection_confidence' => $embedding['detection_confidence'] ?? 0,
|
|
'attributes' => [
|
|
'age' => $embedding['attributes']['age'] ?? null,
|
|
'gender' => $embedding['attributes']['gender'] ?? null,
|
|
],
|
|
];
|
|
}, $face_embeddings);
|
|
|
|
return new WP_REST_Response([
|
|
'success' => true,
|
|
'data' => [
|
|
'uuid' => $uuid,
|
|
'total_vectors' => count($vectors),
|
|
'vectors' => $vectors,
|
|
],
|
|
]);
|
|
}
|
|
|
|
private function format_identity_list_item(array $identity): array {
|
|
return [
|
|
'uuid' => $identity['uuid'],
|
|
'name' => $identity['name'],
|
|
'type' => $identity['identity_type'],
|
|
'source' => $identity['source'],
|
|
'total_references' => (int) ($identity['total_references'] ?? 0),
|
|
'quality_avg' => $identity['quality_avg'] ? round((float) $identity['quality_avg'], 3) : null,
|
|
'trace_duration' => $identity['trace_duration'] ? round((float) $identity['trace_duration'], 2) : null,
|
|
'trace_confidence' => $identity['trace_confidence'] ? round((float) $identity['trace_confidence'], 4) : null,
|
|
'tmdb_id' => $identity['tmdb_id'],
|
|
'created_at' => $identity['created_at'],
|
|
];
|
|
}
|
|
|
|
private function format_identity_detail(array $identity): array {
|
|
$reference_data = json_decode($identity['reference_data'], true);
|
|
|
|
return [
|
|
'uuid' => $identity['uuid'],
|
|
'name' => $identity['name'],
|
|
'type' => $identity['identity_type'],
|
|
'source' => $identity['source'],
|
|
'tmdb_id' => $identity['tmdb_id'],
|
|
'tmdb_profile' => $identity['tmdb_profile'],
|
|
'reference_vectors' => [
|
|
'total' => $reference_data['total_references'] ?? 0,
|
|
'angles' => $reference_data['angles_covered'] ?? [],
|
|
'quality_avg' => $reference_data['quality_avg'] ?? null,
|
|
],
|
|
'trace_stats' => $reference_data['trace_stats'] ?? null,
|
|
'created_at' => $identity['created_at'],
|
|
'updated_at' => $identity['updated_at'],
|
|
];
|
|
}
|
|
|
|
private function calculate_angle_coverage(?array $reference_data): array {
|
|
if (!$reference_data) {
|
|
return [
|
|
'angles' => [],
|
|
'score' => 0,
|
|
'recommendation' => 'No reference data available',
|
|
];
|
|
}
|
|
|
|
$face_embeddings = $reference_data['face_embeddings'] ?? [];
|
|
$required_angles = ['frontal', 'three_quarter', 'profile_left', 'profile_right'];
|
|
|
|
$angle_stats = [];
|
|
foreach ($required_angles as $angle) {
|
|
$angle_stats[$angle] = [
|
|
'count' => 0,
|
|
'quality_sum' => 0,
|
|
'status' => 'missing',
|
|
];
|
|
}
|
|
|
|
foreach ($face_embeddings as $embedding) {
|
|
$angle = $embedding['angle'] ?? 'unknown';
|
|
$quality = $embedding['quality_score'] ?? 0;
|
|
|
|
if (isset($angle_stats[$angle])) {
|
|
$angle_stats[$angle]['count']++;
|
|
$angle_stats[$angle]['quality_sum'] += $quality;
|
|
}
|
|
}
|
|
|
|
$covered_count = 0;
|
|
$total_quality = 0;
|
|
|
|
foreach ($angle_stats as $angle => &$stats) {
|
|
if ($stats['count'] > 0) {
|
|
$stats['quality_avg'] = round($stats['quality_sum'] / $stats['count'], 3);
|
|
$stats['status'] = $stats['count'] > 10 ? 'dominant' : 'present';
|
|
$covered_count++;
|
|
$total_quality += $stats['quality_avg'];
|
|
}
|
|
unset($stats['quality_sum']);
|
|
}
|
|
|
|
$coverage_score = round(($covered_count / count($required_angles)) * 100);
|
|
$avg_quality = $covered_count > 0 ? round($total_quality / $covered_count, 3) : 0;
|
|
|
|
$missing_angles = array_keys(array_filter($angle_stats, fn($s) => $s['count'] === 0));
|
|
$recommendation = 'Excellent coverage!';
|
|
|
|
if (count($missing_angles) > 0) {
|
|
$recommendation = 'Add ' . implode(', ', $missing_angles) . ' angle(s) for better coverage';
|
|
}
|
|
|
|
return [
|
|
'angles' => $angle_stats,
|
|
'score' => $coverage_score,
|
|
'quality_avg' => $avg_quality,
|
|
'recommendation' => $recommendation,
|
|
];
|
|
}
|
|
} |