Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
vendor/
composer.lock
tests/fixtures/current_response.json
1 change: 1 addition & 0 deletions .phpunit.result.cache
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"version":2,"defects":{"OpenAICompatibleTest::testGenerateTagsDoesNotWriteDescriptionWhenDisabled":5,"OpenAICompatibleTest::testGenerateTagsDoesNotWriteDescriptionWhenDescriptionEmpty":5},"times":{"OpenAICompatibleTest::testGetInfoReturnsRequiredKeys":0.001,"OpenAICompatibleTest::testGetConfParamsReturnsAllFields":0,"OpenAICompatibleTest::testGetConfFieldTypes":0,"OpenAICompatibleTest::testGenerateTagsMissingEndpointThrowsException":0,"OpenAICompatibleTest::testGenerateTagsMissingModelThrowsException":0,"OpenAICompatibleTest::testGenerateTagsThrowsWhenImageFileUnreadable":0.001,"OpenAICompatibleTest::testGenerateTagsWithValidJsonResponse":0.001,"OpenAICompatibleTest::testGenerateTagsWithMarkdownFencedJson":0,"OpenAICompatibleTest::testGenerateTagsWithUnlabelledMarkdownFence":0,"OpenAICompatibleTest::testGenerateTagsReturnsEmptyArrayWhenTagsKeyMissing":0,"OpenAICompatibleTest::testGenerateTagsRespectsLimit":0,"OpenAICompatibleTest::testGenerateTagsWritesDescriptionToDatabase":0.001,"OpenAICompatibleTest::testGenerateTagsDescriptionUsesCorrectImageId":0,"OpenAICompatibleTest::testGenerateTagsDoesNotWriteDescriptionWhenDisabled":0,"OpenAICompatibleTest::testGenerateTagsDoesNotWriteDescriptionWhenDescriptionEmpty":0,"OpenAICompatibleTest::testGenerateTagsWithFreeTextFallback":0,"OpenAICompatibleTest::testFreeTextFallbackFiltersLongSentences":0,"OpenAICompatibleTest::testFreeTextFallbackFiltersEntryLongerThan50Chars":0,"OpenAICompatibleTest::testFreeTextFallbackRespectsLimit":0,"OpenAICompatibleTest::testFreeTextFallbackWritesRawContentAsDescription":0,"OpenAICompatibleTest::testGenerateTagsThrowsOnMalformedApiResponse":0,"OpenAICompatibleTest::testGenerateTagsThrowsOnConnectionError":0}}
44 changes: 40 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,50 @@ Generate and automatically apply tags to a bunch of image on the batch manager.

## Available APIs
- [Imagga](https://imagga.com)
- **New** [Microsft Azure](https://azure.microsoft.com/fr-fr/services/cognitive-services/computer-vision/) (you'll have to create an application on Azure)
- [Microsoft Azure Computer Vision](https://azure.microsoft.com/fr-fr/services/cognitive-services/computer-vision/) (you'll have to create an application on Azure)
- **New** OpenAI-compatible self-hosted endpoint (llama.cpp, Ollama, vLLM, LiteLLM, OpenAI, etc.)

## Usage
* Install the plugin on your Piwigo
* Create an Imagga Account on https://imagga.com/auth/signup (or [here](https://azure.microsoft.com/fr-fr/free/cognitive-services/) for Microsoft Azure)
* Enter your api token and api secret on the plugin configuration page
* Create an Imagga Account on https://imagga.com/auth/signup (or [here](https://azure.microsoft.com/fr-fr/free/cognitive-services/) for Microsoft Azure), or set up a self-hosted vision model (see below)
* Enter your API credentials on the plugin configuration page
* Go to an image modification page and click on the little robot next to the tags input
* Generate tags, select the ones you want and apply them

### Self-hosted / OpenAI-compatible backend

The **OpenAI-compatible** provider works with any server that implements the `/v1/chat/completions` endpoint with vision support. Common options:

| Server | Example base URL |
|--------|-----------------|
| [Ollama](https://ollama.com) | `http://localhost:11434` |
| [llama.cpp](https://github.com/ggerganov/llama.cpp) | `http://localhost:8080` |
| [vLLM](https://github.com/vllm-project/vllm) | `http://localhost:8000` |
| [LiteLLM](https://github.com/BerriAI/litellm) | `http://localhost:4000` |
| OpenAI | `https://api.openai.com` |

**Configuration fields:**

| Field | Description |
|-------|-------------|
| API Base URL | Base URL of the server (the plugin appends `/v1/chat/completions`) |
| API Key | Bearer token — leave empty or set to `none` for local servers that don't require auth |
| Model Name | The vision-capable model to use (e.g. `llava`, `gpt-4o`, `llava-llama3`) |
| Max Tokens | Maximum tokens in the model's response (default 300) |
| Custom Prompt | Override the default JSON prompt. Leave empty to use the built-in prompt. |
| Write description as photo comment | When checked, the model's image description is also saved to the photo's comment field. |

The plugin sends the image as a base64-encoded `image_url` content block, which is standard across all OpenAI-compatible vision APIs.

**Example with Ollama:**
```
# Pull a vision model
ollama pull llava

# Start Ollama (it listens on port 11434 by default)
ollama serve
```
Then set API Base URL to `http://localhost:11434` and Model Name to `llava`.

### Warning
As this plugin use an external API, we cannot assure you that your data will not be used or sold. I recommend you to check the data policy of each external API you use with this plugin.
As this plugin uses an external API, we cannot assure you that your data will not be used or sold. I recommend you to check the data policy of each external API you use with this plugin. Using a self-hosted model keeps all image data on your own infrastructure.
39 changes: 24 additions & 15 deletions admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,35 +24,44 @@
$newConf->setSelectedAPI($_POST['api']);
}

$apiObject = tr_getAPI($_POST['api']);
$apiObject = tr_getAPI($_POST['api']);
$fieldTypes = $apiObject->getConfFieldTypes();
foreach ($apiObject->getConfParams() as $key => $value) {
$newConf->setParam($_POST['api'], $key, trim($_POST[$key]));
$type = isset($fieldTypes[$key]) ? $fieldTypes[$key] : 'text';
if ($type === 'checkbox') {
$newConf->setParam($_POST['api'], $key, isset($_POST[$key]) ? '1' : '0');
} else {
$newConf->setParam($_POST['api'], $key, trim($_POST[$key] ?? ''));
}
}
tr_setConf($newConf);
}
}

$tr_api_info = [];
$tr_api_params = [];
$tr_api_conf = [];
$tr_api_info = [];
$tr_api_params = [];
$tr_api_conf = [];
$tr_api_field_types = [];

foreach (TR_API_LIST as $apiName) {
$apiObject = tr_getAPI($apiName);
$tr_api_info[$apiName] = $apiObject->getInfo();
$tr_api_params[$apiName] = $apiObject->getConfParams();
$tr_api_conf[$apiName] = tr_getConf()->getConf($apiName);
$tr_api_info[$apiName] = $apiObject->getInfo();
$tr_api_params[$apiName] = $apiObject->getConfParams();
$tr_api_conf[$apiName] = tr_getConf()->getConf($apiName);
$tr_api_field_types[$apiName] = $apiObject->getConfFieldTypes();
}



$template->assign(array(
'TR_PATH' => TR_PATH,
'TR_API_LIST' => TR_API_LIST,
'TR_API_INFO' => $tr_api_info,
'TR_API_PARAMS' => $tr_api_params,
'TR_API_CONF' => $tr_api_conf,
'TR_API_SELECTED' => tr_getConf()->getSelectedAPI(),
'PWG_TOKEN' => get_pwg_token()
'TR_PATH' => TR_PATH,
'TR_API_LIST' => TR_API_LIST,
'TR_API_INFO' => $tr_api_info,
'TR_API_PARAMS' => $tr_api_params,
'TR_API_CONF' => $tr_api_conf,
'TR_API_FIELD_TYPES' => $tr_api_field_types,
'TR_API_SELECTED' => tr_getConf()->getSelectedAPI(),
'PWG_TOKEN' => get_pwg_token()
));

$template->set_filename('plugin_admin_content', realpath(TR_PATH . 'template/admin.tpl'));
Expand Down
174 changes: 174 additions & 0 deletions api_classes/OpenAICompatible.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
<?php

class OpenAICompatible extends API {

function getInfo() : array
{
return [
"icon" => '',
"site" => 'https://platform.openai.com/docs/api-reference/chat',
"info" => 'Self-hosted OpenAI-compatible vision API. Works with any server that implements the /v1/chat/completions endpoint, including llama.cpp, Ollama, vLLM, LiteLLM, and OpenAI itself. Requires a vision-capable model.',
];
}

function getConfParams() : array
{
return [
'ENDPOINT' => 'API Base URL',
'API_KEY' => 'API Key (optional)',
'MODEL' => 'Model Name',
'MAX_TOKENS' => 'Max Tokens',
'PROMPT' => 'Custom Prompt (optional)',
'WRITE_DESCRIPTION' => 'Write description as photo comment',
];
}

function getConfFieldTypes() : array
{
return [
'PROMPT' => 'textarea',
'WRITE_DESCRIPTION' => 'checkbox',
];
}

function generateTags($conf, $params) : array
{
$file_path = $this->getFileName($params['imageId']);

if (empty($conf['ENDPOINT']) || empty($conf['MODEL']))
throw new Exception('API parameters are not set');

// getFileName() returns a derivative URL or path. Try to resolve it to a
// filesystem path so file_get_contents() works without an HTTP round-trip.
$fs_path = $file_path;
if (defined('PHPWG_ROOT_PATH') && !preg_match('/^https?:\/\//', $file_path)) {
$candidate = realpath(PHPWG_ROOT_PATH . $file_path);
if ($candidate !== false && file_exists($candidate)) {
$fs_path = $candidate;
}
}

$pathinfo = pathinfo($fs_path);
$mime_types = [
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'png' => 'image/png',
'webp' => 'image/webp',
'gif' => 'image/gif',
];
$ext = strtolower($pathinfo['extension'] ?? 'jpg');
$mime_content_type = $mime_types[$ext] ?? 'image/jpeg';

$data = @file_get_contents($fs_path);
if ($data === false)
throw new Exception('Cannot read image file: ' . $fs_path);

$base64 = base64_encode($data);
$dataUri = 'data:' . $mime_content_type . ';base64,' . $base64;

$limit = (int)($params['limit'] ?? 20);
$prompt = !empty($conf['PROMPT'])
? $conf['PROMPT']
: 'Analyze this image and respond with a JSON object containing two keys: "description" (a 2-3 sentence description of the image) and "tags" (an array of up to ' . $limit . ' relevant keyword tags). Respond with only the JSON object, no markdown or extra text.';

$max_tokens = !empty($conf['MAX_TOKENS']) ? (int)$conf['MAX_TOKENS'] : 300;

$payload = [
'model' => $conf['MODEL'],
'max_tokens' => $max_tokens,
'messages' => [
[
'role' => 'user',
'content' => [
[
'type' => 'text',
'text' => $prompt,
],
[
'type' => 'image_url',
'image_url' => ['url' => $dataUri],
],
],
],
],
];

$endpoint = rtrim($conf['ENDPOINT'], '/') . '/v1/chat/completions';

$api_key = !empty($conf['API_KEY']) ? $conf['API_KEY'] : 'none';

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $endpoint);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
// 120s is enough for most local models; increase for large models on slow hardware
curl_setopt($ch, CURLOPT_TIMEOUT, 120);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Authorization: Bearer ' . $api_key,
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));

$response = curl_exec($ch);

if (curl_errno($ch)) {
$error = curl_error($ch);
curl_close($ch);
throw new Exception('Connection error: ' . $error);
}
curl_close($ch);

$json_response = json_decode($response);

if (!isset($json_response->choices[0]->message->content))
throw new Exception('API Error: ' . $response);

$content = $json_response->choices[0]->message->content;

// Strip markdown code fences that some models add
$content = preg_replace('/^```(?:json)?\s*/m', '', $content);
$content = preg_replace('/\s*```\s*$/m', '', $content);
$content = trim($content);

$parsed = json_decode($content, true);

$tags = [];
$description = '';

if (is_array($parsed)) {
if (!empty($parsed['tags']) && is_array($parsed['tags'])) {
$tags = array_slice(array_values($parsed['tags']), 0, $limit);
}
if (!empty($parsed['description']) && is_string($parsed['description'])) {
$description = $parsed['description'];
}
} else {
// Fallback for models that return free text instead of JSON.
// Use the raw content as the description and try to split it into tags.
$description = $content;

$candidates = preg_split('/[\n,]+/', $content);
foreach ($candidates as $candidate) {
$candidate = trim($candidate);
// Skip empty strings, sentences (> 5 words), and overly long entries
if ($candidate === '') continue;
if (str_word_count($candidate) > 5) continue;
if (strlen($candidate) > 50) continue;
$tags[] = $candidate;
if (count($tags) >= $limit) break;
}
}

if (!empty($conf['WRITE_DESCRIPTION']) && $conf['WRITE_DESCRIPTION'] === '1' && !empty($description)) {
$query = '
UPDATE ' . IMAGES_TABLE . '
SET comment = \'' . pwg_db_real_escape_string($description) . '\'
WHERE id = ' . ((int)$params['imageId']) . '
;';
pwg_query($query);
}

return $tags;
}
}
14 changes: 14 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "piwigo/tag-recognition",
"description": "Piwigo plugin to suggest tags on images using external APIs",
"type": "piwigo-plugin",
"license": "MIT",
"require-dev": {
"phpunit/phpunit": "^10.0"
},
"config": {
"platform": {
"php": "8.3"
}
}
}
14 changes: 12 additions & 2 deletions include/api_types.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php

define('TR_API_LIST', ['Imagga', 'Azure']);
define('TR_API_LIST', ['Imagga', 'Azure', 'OpenAICompatible']);

abstract class API
{
Expand All @@ -15,7 +15,17 @@ abstract function getInfo() : array ;
/**
* Return an array key-value of the essential configuration of the api
*/
abstract function getConfParams() : array ;
abstract function getConfParams() : array ;

/**
* Return an array of field types keyed by conf param name.
* Supported types: 'text' (default), 'textarea', 'checkbox'
* Params not listed here are rendered as plain text inputs.
*/
function getConfFieldTypes() : array
{
return [];
}

/**
* Generate tags with the API
Expand Down
3 changes: 2 additions & 1 deletion include/functions.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ function tr_getAPI($api) : API {
$ret = new Imagga();

if ($api == 'Azure') $ret = new Azure();

if ($api == 'OpenAICompatible') $ret = new OpenAICompatible();

return $ret;
}

Expand Down
7 changes: 7 additions & 0 deletions language/en_UK/plugin.lang.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,10 @@
$lang['You haven\'t enough request left this month for this action'] = 'You haven\'t enough request left this month for this action';
$lang['Tag successfully generated and added'] = "Tag successfully generated and added";
$lang['Loading... %d1/%d2 processed photos'] = 'Loading... %d1/%d2 processed photos';
$lang['API Base URL'] = 'API Base URL';
$lang['API Key (optional)'] = 'API Key (optional)';
$lang['Model Name'] = 'Model Name';
$lang['Max Tokens'] = 'Max Tokens';
$lang['Custom Prompt (optional)'] = 'Custom Prompt (optional)';
$lang['Write description as photo comment'] = 'Write description as photo comment';
$lang['Save Settings'] = 'Save Settings';
13 changes: 13 additions & 0 deletions phpunit.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="tests/bootstrap.php"
colors="true"
failOnRisky="true"
failOnWarning="true">
<testsuites>
<testsuite name="Tag Recognition">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>
Loading