Initial import s190js
This commit is contained in:
commit
73b44d24e5
296 changed files with 102988 additions and 0 deletions
45
.env
Normal file
45
.env
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
BASE_URL=https://prova.patachina.it
|
||||
SERVER_PORT=4000
|
||||
|
||||
# cartella dove scrivere gli output HLS (relativa alla root del progetto)
|
||||
HLS_BASE=./hls_output
|
||||
|
||||
EMAIL=fabio@gmail.com
|
||||
PASSWORD=master66
|
||||
|
||||
JWT_SECRET=123456789
|
||||
JWT_EXPIRES=1h
|
||||
|
||||
# Dove si trova la cartella public (relativa alla root del progetto)
|
||||
WEB_ROOT=public
|
||||
|
||||
# Percorso relativo di index.json dentro public/
|
||||
INDEX_PATH=photos/index.json
|
||||
|
||||
# true = restituisce path assoluti nei record
|
||||
PATH_FULL=true
|
||||
|
||||
# Logging
|
||||
LOG_MODE=both # console | file | both
|
||||
LOG_FILE=scan.log
|
||||
LOG_DIR=public
|
||||
LOG_VERBOSE=true
|
||||
|
||||
# POLLING_TIME in secondi
|
||||
# Funzionamento:
|
||||
# - POLLING_TIME = 0 → polling disattivato (solo WebSocket)
|
||||
# - POLLING_TIME > 0 → esegue incrementalSync() ogni N secondi
|
||||
|
||||
# Perché esiste?
|
||||
# - Serve come "rete di sicurezza" se il WebSocket cade,
|
||||
# se la tab viene sospesa, o se qualche evento viene perso.
|
||||
# - Google Photos, Dropbox e iCloud usano lo stesso approccio.
|
||||
GALLERY_REFRESH_SECONDS=30
|
||||
|
||||
WS_PORT=4002
|
||||
WS_HOST=0.0.0.0
|
||||
|
||||
PHOTO_RETENTION_DAYS=30
|
||||
|
||||
MAPBOX_PUBLIC_TOKEN=pk.eyJ1IjoicGlwcG9jYWwiLCJhIjoiY2xka28zdWVjMHBkZzNwbnoyMmlxYjA3MyJ9.9D5EB0SWydvAQZr-SdI64Q
|
||||
WORKER_STATUS_REFRESH_SECONDS=1
|
||||
65
.gitignore
vendored
Normal file
65
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# Node
|
||||
node_modules/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
.venv-clustering/
|
||||
|
||||
# SQLite
|
||||
*.sqlite
|
||||
*.sqlite-shm
|
||||
*.sqlite-wal
|
||||
api_v1/database.sqlite.pre-clear-*
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Locks
|
||||
*.lock
|
||||
|
||||
# Media
|
||||
public/photos/
|
||||
public/activities/
|
||||
public/cast/
|
||||
public/del_photos/
|
||||
public/del_activities/
|
||||
|
||||
del_photos/
|
||||
del_activities/
|
||||
|
||||
# Runtime
|
||||
hls_output/
|
||||
out/
|
||||
out_fast/
|
||||
out_libx264/
|
||||
thumbs/
|
||||
var/
|
||||
|
||||
# Face runtime binaries
|
||||
native/face-knn/bin/
|
||||
|
||||
# C/C++
|
||||
build/
|
||||
*.o
|
||||
*.a
|
||||
*.so
|
||||
|
||||
# Backup
|
||||
*.bak*
|
||||
*.old
|
||||
*.orig
|
||||
*.before_*
|
||||
*.before-*
|
||||
*.ok
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Backup e versioni temporanee sorgenti
|
||||
*.js.*
|
||||
*.css.*
|
||||
*.html.*
|
||||
*.sqlite.*
|
||||
129
README.md
Normal file
129
README.md
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
# Galleria con json-server e protetto con JWT S6J
|
||||
|
||||
## Installazione
|
||||
|
||||
clonare questa repo e installare tutte le dipendenze con `npm ci`
|
||||
|
||||
|
||||
## Start/Stop servers
|
||||
|
||||
| Description | Script |
|
||||
| ------------------------- | -------------------- |
|
||||
| Start server senza auth | `npm start-no-auth` |
|
||||
| Start server con auth | `npm run start` |
|
||||
|
||||
## Tools
|
||||
|
||||
| Description | Script |
|
||||
| ------------------------------ | ------------------- |
|
||||
| Generate user hashed passwords | `npm run hash` |
|
||||
|
||||
|
||||
[json-server api reference](https://github.com/typicode/json-server)
|
||||
|
||||
## Come usarlo
|
||||
|
||||
clonare e poi installare con
|
||||
|
||||
```
|
||||
npm ci
|
||||
```
|
||||
|
||||
nel file .env ci sono tutti i dati da modificare
|
||||
|
||||
poi inserire in user.json user e password utilizzati per fare il login
|
||||
|
||||
la password da inserire è criptata e viene generata con npm run hash
|
||||
|
||||
il nome viene utilizzato come cartella da scansionare, si trova dentro photos
|
||||
|
||||
es:
|
||||
```
|
||||
name: Fabio
|
||||
|
||||
public/photos
|
||||
└── Fabio
|
||||
└── original
|
||||
└── 2017Irlanda19-29ago
|
||||
├── IMG_0092.JPG
|
||||
├── IMG_0099.JPG
|
||||
├── IMG_0100.JPG
|
||||
```
|
||||
poi dentro Fabio genererà thumbs con tutti i thumbs
|
||||
|
||||
- npm run start
|
||||
- su IP:4000 ci sarà la galleria e andando su impostazioni si potrà fare lo scan di tutte le foto
|
||||
|
||||
dopo aver fatto lo scan è possibile richiedere il json al server con tutte le informazioni anche senza autorizzazione
|
||||
|
||||
basta farlo partire con npm run start-no-auth e le info si possono vedere con
|
||||
|
||||
ip:4000/photos
|
||||
|
||||
- npm start
|
||||
|
||||
|
||||
---
|
||||
|
||||
#### FIT parse (utilizzo di python)
|
||||
|
||||
Per avere il parse dei fit installare python, pipx e i seguenyi packages
|
||||
|
||||
```
|
||||
sudo apt install pipx
|
||||
pipx ensurepath
|
||||
pipx install fitdecode
|
||||
```
|
||||
|
||||
usa per sapere dove è installato python
|
||||
|
||||
```
|
||||
pipx list
|
||||
```
|
||||
in api_v1/scanner/parsers/parseFit.js va inserito il riferimento
|
||||
|
||||
in questo caso
|
||||
|
||||
```
|
||||
async function parseFit(absPath) {
|
||||
const frames = await new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
"/home/orangepi/.local/pipx/venvs/fitdecode/bin/python",
|
||||
["api_v1/scanner/parsers/parse_fit.py", absPath],
|
||||
{ maxBuffer: 1024 * 1024 * 50 }, // 50 MB
|
||||
```
|
||||
|
||||
Inspired in this [post](https://www.techiediaries.com/fake-api-jwt-json-server/) by [Techiediaries](https://www.techiediaries.com/)
|
||||
|
||||
#### Interrogazioni manuali
|
||||
|
||||
per vedere il token
|
||||
|
||||
```
|
||||
curl -s -X POST https://prova.patachina.it/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"fabio@gmail.com","password":"master66"}'
|
||||
```
|
||||
|
||||
per salvare il token
|
||||
|
||||
```
|
||||
TOKEN=$(curl -s -X POST https://prova.patachina.it/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"fabio@gmail.com","password":"master66"}' \
|
||||
| jq -r '.token')
|
||||
```
|
||||
|
||||
per interrogare le foto
|
||||
|
||||
```
|
||||
curl -s "https://prova.patachina.it/photos/" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
per interrogare le attività
|
||||
|
||||
```
|
||||
curl -s "https://prova.patachina.it/activities/" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
4
api_v1/admin_secret.json
Normal file
4
api_v1/admin_secret.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"email": "admin@gmail.com",
|
||||
"password": "master66"
|
||||
}
|
||||
36
api_v1/config.js
Normal file
36
api_v1/config.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
require('dotenv').config();
|
||||
|
||||
module.exports = {
|
||||
BASE_URL: process.env.BASE_URL,
|
||||
|
||||
EMAIL: process.env.EMAIL,
|
||||
PASSWORD: process.env.PASSWORD,
|
||||
|
||||
SEND_PHOTOS: (process.env.SEND_PHOTOS || 'true').toLowerCase() === 'true',
|
||||
|
||||
// Cartella public (root dei file statici)
|
||||
WEB_ROOT: process.env.WEB_ROOT || 'public',
|
||||
|
||||
// PATH_FULL ora è un boolean, non un path
|
||||
PATH_FULL: (process.env.PATH_FULL || 'false').toLowerCase() === 'true',
|
||||
|
||||
// Estensioni supportate per FOTO/VIDEO
|
||||
SUPPORTED_EXTS: new Set([
|
||||
'.jpg', '.jpeg', '.png', '.webp', '.heic', '.heif',
|
||||
'.mp4', '.mov', '.m4v'
|
||||
]),
|
||||
|
||||
// Estensioni supportate per ATTIVITÀ (FIT/GPX/TCX)
|
||||
SUPPORTED_ACTIVITY_EXTS: new Set([
|
||||
'.fit',
|
||||
'.gpx',
|
||||
'.tcx'
|
||||
]),
|
||||
|
||||
// 🔥 NUOVO: intervallo refresh gallery (in secondi)
|
||||
GALLERY_REFRESH_SECONDS: Number(process.env.GALLERY_REFRESH_SECONDS || 30),
|
||||
WORKER_STATUS_REFRESH_SECONDS: Number(process.env.WORKER_STATUS_REFRESH_SECONDS || 1),
|
||||
|
||||
// 🔥 PORTA SERVER
|
||||
PORT: parseInt(process.env.SERVER_PORT || "4000", 10)
|
||||
};
|
||||
128
api_v1/geo.js
Normal file
128
api_v1/geo.js
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
// api_v1/geo.js
|
||||
const axios = require("axios");
|
||||
|
||||
async function loc(lng, lat) {
|
||||
const primary = await place(lng, lat);
|
||||
|
||||
if (
|
||||
primary &&
|
||||
primary.city &&
|
||||
primary.country &&
|
||||
primary.address
|
||||
) {
|
||||
return primary;
|
||||
}
|
||||
|
||||
const fallback = await placePhoton(lng, lat);
|
||||
|
||||
if (!primary) return fallback;
|
||||
|
||||
if (!primary.city && fallback?.city) {
|
||||
primary.city = fallback.city;
|
||||
}
|
||||
|
||||
if (!primary.postcode && fallback?.postcode) {
|
||||
primary.postcode = fallback.postcode;
|
||||
}
|
||||
|
||||
if (!primary.address && fallback?.address) {
|
||||
primary.address = fallback.address;
|
||||
}
|
||||
|
||||
if (!primary.region && fallback?.region) {
|
||||
primary.region = fallback.region;
|
||||
}
|
||||
|
||||
if (!primary.country_code && fallback?.country_code) {
|
||||
primary.country_code = fallback.country_code;
|
||||
}
|
||||
|
||||
if (!primary.county && fallback?.county) {
|
||||
primary.county = fallback.county;
|
||||
}
|
||||
|
||||
if (!primary.county_code && fallback?.county_code) {
|
||||
primary.county_code = fallback.county_code;
|
||||
}
|
||||
|
||||
return primary;
|
||||
}
|
||||
|
||||
function normCountryCode(v) {
|
||||
if (!v) return undefined;
|
||||
const s = String(v).trim().toUpperCase();
|
||||
return s.length ? s : undefined;
|
||||
}
|
||||
|
||||
async function place(lng, lat) {
|
||||
const apiKey = "6dc7fb95a3b246cfa0f3bcef5ce9ed9a";
|
||||
const url = `https://api.geoapify.com/v1/geocode/reverse?lat=${lat}&lon=${lng}&apiKey=${apiKey}`;
|
||||
|
||||
try {
|
||||
const r = await axios.get(url);
|
||||
|
||||
if (r.status !== 200) return undefined;
|
||||
if (!r.data.features || r.data.features.length === 0) return undefined;
|
||||
|
||||
const k = r.data.features[0].properties;
|
||||
|
||||
return {
|
||||
continent: k?.timezone?.name?.split("/")?.[0] || undefined,
|
||||
|
||||
// Country / Paese
|
||||
country: k?.country || undefined,
|
||||
country_code: normCountryCode(k?.country_code),
|
||||
|
||||
// County / contea
|
||||
county: k?.county || undefined,
|
||||
county_code: k?.county_code || undefined,
|
||||
|
||||
// Regione / stato amministrativo
|
||||
region: k?.state || undefined,
|
||||
|
||||
postcode: k?.postcode || undefined,
|
||||
city: k?.city || k?.town || k?.village || undefined,
|
||||
address: k?.address_line1 || undefined,
|
||||
timezone: k?.timezone?.name || undefined,
|
||||
time: k?.timezone?.offset_STD || undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function placePhoton(lng, lat) {
|
||||
try {
|
||||
const url = `https://photon.patachina.it/reverse?lon=${lng}&lat=${lat}`;
|
||||
const r = await axios.get(url);
|
||||
|
||||
if (!r.data || !r.data.features || r.data.features.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const p = r.data.features[0].properties;
|
||||
|
||||
return {
|
||||
continent: undefined,
|
||||
|
||||
// Country / Paese
|
||||
country: p.country || undefined,
|
||||
country_code: normCountryCode(p.countrycode || p.country_code),
|
||||
|
||||
// County / contea, se disponibile
|
||||
county: p.county || undefined,
|
||||
county_code: p.county_code || undefined,
|
||||
|
||||
region: p.state || undefined,
|
||||
postcode: p.postcode || undefined,
|
||||
city: p.city || p.town || p.village || undefined,
|
||||
address: p.street ? `${p.street} ${p.housenumber || ""}`.trim() : undefined,
|
||||
timezone: undefined,
|
||||
time: undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = loc;
|
||||
527
api_v1/scanner/activityIngestQueue.js
Normal file
527
api_v1/scanner/activityIngestQueue.js
Normal file
|
|
@ -0,0 +1,527 @@
|
|||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
const {
|
||||
withSqliteBusyRetry,
|
||||
} = require('../../db/sqliteRetry');
|
||||
|
||||
const TABLE = 'activity_ingest_jobs';
|
||||
|
||||
function makeJobId(user, cartella, relPath) {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(JSON.stringify([user, cartella, relPath]))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
async function ensureActivityIngestQueue(db) {
|
||||
await withSqliteBusyRetry(
|
||||
'activity queue ensure',
|
||||
async () => {
|
||||
if (!(await db.schema.hasTable(TABLE))) {
|
||||
await db.schema.createTable(TABLE, (t) => {
|
||||
t.string('id').primary();
|
||||
t.string('user').notNullable();
|
||||
t.string('cartella').notNullable();
|
||||
t.string('rel_path').notNullable();
|
||||
|
||||
t.integer('source_mtime_ms').notNullable();
|
||||
t.integer('source_size_bytes').notNullable();
|
||||
|
||||
t.string('status')
|
||||
.notNullable()
|
||||
.defaultTo('pending');
|
||||
|
||||
t.integer('attempts')
|
||||
.notNullable()
|
||||
.defaultTo(0);
|
||||
|
||||
t.integer('rescan_requested')
|
||||
.notNullable()
|
||||
.defaultTo(0);
|
||||
|
||||
t.text('last_error').nullable();
|
||||
|
||||
t.datetime('started_at').nullable();
|
||||
t.datetime('completed_at').nullable();
|
||||
|
||||
t.datetime('created_at')
|
||||
.notNullable()
|
||||
.defaultTo(db.fn.now());
|
||||
|
||||
t.datetime('updated_at')
|
||||
.notNullable()
|
||||
.defaultTo(db.fn.now());
|
||||
|
||||
t.index(
|
||||
['status', 'created_at'],
|
||||
'idx_activity_ingest_status_created'
|
||||
);
|
||||
|
||||
t.index(
|
||||
['user', 'cartella'],
|
||||
'idx_activity_ingest_folder'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!(await db.schema.hasColumn(
|
||||
TABLE,
|
||||
'rescan_requested'
|
||||
))
|
||||
) {
|
||||
await db.schema.alterTable(TABLE, (t) => {
|
||||
t.integer('rescan_requested')
|
||||
.notNullable()
|
||||
.defaultTo(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function enqueueActivityIngestJob(db, input) {
|
||||
const {
|
||||
user,
|
||||
cartella,
|
||||
relPath,
|
||||
mtimeMs,
|
||||
sizeBytes,
|
||||
} = input;
|
||||
|
||||
if (
|
||||
typeof user !== 'string' || !user ||
|
||||
typeof cartella !== 'string' || !cartella ||
|
||||
typeof relPath !== 'string' || !relPath ||
|
||||
!Number.isFinite(mtimeMs) || mtimeMs < 0 ||
|
||||
!Number.isFinite(sizeBytes) || sizeBytes < 0
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Dati non validi per accodare activity'
|
||||
);
|
||||
}
|
||||
|
||||
await ensureActivityIngestQueue(db);
|
||||
|
||||
const id = makeJobId(user, cartella, relPath);
|
||||
const mtime = Math.trunc(mtimeMs);
|
||||
const size = Math.trunc(sizeBytes);
|
||||
|
||||
return withSqliteBusyRetry(
|
||||
'activity queue enqueue',
|
||||
() => db.transaction(async (trx) => {
|
||||
const existing = await trx(TABLE)
|
||||
.where({ id })
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
const unchanged =
|
||||
Number(existing.source_mtime_ms) === mtime &&
|
||||
Number(existing.source_size_bytes) === size;
|
||||
|
||||
if (unchanged) {
|
||||
return {
|
||||
id,
|
||||
status: existing.status,
|
||||
unchanged: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (existing.status === 'processing') {
|
||||
await trx(TABLE)
|
||||
.where({
|
||||
id,
|
||||
status: 'processing',
|
||||
})
|
||||
.update({
|
||||
source_mtime_ms: mtime,
|
||||
source_size_bytes: size,
|
||||
rescan_requested: 1,
|
||||
updated_at: trx.fn.now(),
|
||||
});
|
||||
|
||||
return {
|
||||
id,
|
||||
status: 'processing',
|
||||
changedWhileProcessing: true,
|
||||
};
|
||||
}
|
||||
|
||||
await trx(TABLE)
|
||||
.where({ id })
|
||||
.update({
|
||||
source_mtime_ms: mtime,
|
||||
source_size_bytes: size,
|
||||
status: 'pending',
|
||||
attempts: 0,
|
||||
rescan_requested: 0,
|
||||
last_error: null,
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
updated_at: trx.fn.now(),
|
||||
});
|
||||
|
||||
return {
|
||||
id,
|
||||
status: 'pending',
|
||||
updated: true,
|
||||
};
|
||||
}
|
||||
|
||||
await trx(TABLE).insert({
|
||||
id,
|
||||
user,
|
||||
cartella,
|
||||
rel_path: relPath,
|
||||
source_mtime_ms: mtime,
|
||||
source_size_bytes: size,
|
||||
status: 'pending',
|
||||
attempts: 0,
|
||||
rescan_requested: 0,
|
||||
created_at: trx.fn.now(),
|
||||
updated_at: trx.fn.now(),
|
||||
});
|
||||
|
||||
return {
|
||||
id,
|
||||
status: 'pending',
|
||||
inserted: true,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
async function enqueueActivityIngestJobs(db, inputs) {
|
||||
if (!Array.isArray(inputs)) {
|
||||
throw new TypeError('Il batch deve essere un array');
|
||||
}
|
||||
if (inputs.length === 0) return [];
|
||||
|
||||
for (const input of inputs) {
|
||||
const { user, cartella, relPath, mtimeMs, sizeBytes } = input || {};
|
||||
if (
|
||||
typeof user !== 'string' || !user ||
|
||||
typeof cartella !== 'string' || !cartella ||
|
||||
typeof relPath !== 'string' || !relPath ||
|
||||
!Number.isFinite(mtimeMs) || mtimeMs < 0 ||
|
||||
!Number.isFinite(sizeBytes) || sizeBytes < 0
|
||||
) {
|
||||
throw new TypeError('Dati non validi nel batch activity');
|
||||
}
|
||||
}
|
||||
|
||||
await ensureActivityIngestQueue(db);
|
||||
|
||||
return withSqliteBusyRetry(
|
||||
'activity queue enqueue batch',
|
||||
() => db.transaction(async (trx) => {
|
||||
const results = [];
|
||||
|
||||
for (const input of inputs) {
|
||||
const { user, cartella, relPath, mtimeMs, sizeBytes } = input;
|
||||
const id = makeJobId(user, cartella, relPath);
|
||||
const mtime = Math.trunc(mtimeMs);
|
||||
const size = Math.trunc(sizeBytes);
|
||||
|
||||
const existing = await trx(TABLE).where({ id }).first();
|
||||
|
||||
if (existing) {
|
||||
const unchanged =
|
||||
Number(existing.source_mtime_ms) === mtime &&
|
||||
Number(existing.source_size_bytes) === size;
|
||||
|
||||
if (unchanged) {
|
||||
results.push({
|
||||
id, status: existing.status, unchanged: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existing.status === 'processing') {
|
||||
await trx(TABLE)
|
||||
.where({ id, status: 'processing' })
|
||||
.update({
|
||||
source_mtime_ms: mtime,
|
||||
source_size_bytes: size,
|
||||
rescan_requested: 1,
|
||||
updated_at: trx.fn.now(),
|
||||
});
|
||||
|
||||
results.push({
|
||||
id, status: 'processing', changedWhileProcessing: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
await trx(TABLE).where({ id }).update({
|
||||
source_mtime_ms: mtime,
|
||||
source_size_bytes: size,
|
||||
status: 'pending',
|
||||
attempts: 0,
|
||||
rescan_requested: 0,
|
||||
last_error: null,
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
updated_at: trx.fn.now(),
|
||||
});
|
||||
|
||||
results.push({ id, status: 'pending', updated: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
await trx(TABLE).insert({
|
||||
id,
|
||||
user,
|
||||
cartella,
|
||||
rel_path: relPath,
|
||||
source_mtime_ms: mtime,
|
||||
source_size_bytes: size,
|
||||
status: 'pending',
|
||||
attempts: 0,
|
||||
rescan_requested: 0,
|
||||
created_at: trx.fn.now(),
|
||||
updated_at: trx.fn.now(),
|
||||
});
|
||||
|
||||
results.push({ id, status: 'pending', inserted: true });
|
||||
}
|
||||
|
||||
return results;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function claimNextActivityIngestJob(db) {
|
||||
await ensureActivityIngestQueue(db);
|
||||
|
||||
return withSqliteBusyRetry(
|
||||
'activity queue claim',
|
||||
() => db.transaction(async (trx) => {
|
||||
const job = await trx(TABLE)
|
||||
.where({ status: 'pending' })
|
||||
.orderBy('created_at', 'asc')
|
||||
.first();
|
||||
|
||||
if (!job) return null;
|
||||
|
||||
const changed = await trx(TABLE)
|
||||
.where({
|
||||
id: job.id,
|
||||
status: 'pending',
|
||||
})
|
||||
.update({
|
||||
status: 'processing',
|
||||
attempts: trx.raw('attempts + 1'),
|
||||
rescan_requested: 0,
|
||||
started_at: trx.fn.now(),
|
||||
completed_at: null,
|
||||
last_error: null,
|
||||
updated_at: trx.fn.now(),
|
||||
});
|
||||
|
||||
if (!changed) return null;
|
||||
|
||||
return {
|
||||
...job,
|
||||
status: 'processing',
|
||||
attempts: Number(job.attempts || 0) + 1,
|
||||
rescan_requested: 0,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function completeActivityIngestJob(db, id) {
|
||||
return withSqliteBusyRetry(
|
||||
'activity queue complete',
|
||||
() => db.transaction(async (trx) => {
|
||||
const job = await trx(TABLE)
|
||||
.where({
|
||||
id,
|
||||
status: 'processing',
|
||||
})
|
||||
.first();
|
||||
|
||||
if (!job) {
|
||||
return { updated: false };
|
||||
}
|
||||
|
||||
if (Number(job.rescan_requested) === 1) {
|
||||
await trx(TABLE)
|
||||
.where({
|
||||
id,
|
||||
status: 'processing',
|
||||
})
|
||||
.update({
|
||||
status: 'pending',
|
||||
rescan_requested: 0,
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
last_error: null,
|
||||
updated_at: trx.fn.now(),
|
||||
});
|
||||
|
||||
return {
|
||||
updated: true,
|
||||
status: 'pending',
|
||||
requeued: true,
|
||||
};
|
||||
}
|
||||
|
||||
await trx(TABLE)
|
||||
.where({
|
||||
id,
|
||||
status: 'processing',
|
||||
})
|
||||
.update({
|
||||
status: 'done',
|
||||
completed_at: trx.fn.now(),
|
||||
updated_at: trx.fn.now(),
|
||||
last_error: null,
|
||||
});
|
||||
|
||||
return {
|
||||
updated: true,
|
||||
status: 'done',
|
||||
requeued: false,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function failActivityIngestJob(
|
||||
db,
|
||||
id,
|
||||
error
|
||||
) {
|
||||
const message = String(
|
||||
error?.stack ||
|
||||
error?.message ||
|
||||
error
|
||||
).slice(0, 12000);
|
||||
|
||||
return withSqliteBusyRetry(
|
||||
'activity queue fail',
|
||||
() => db.transaction(async (trx) => {
|
||||
const job = await trx(TABLE)
|
||||
.where({
|
||||
id,
|
||||
status: 'processing',
|
||||
})
|
||||
.first();
|
||||
|
||||
if (!job) {
|
||||
return { updated: false };
|
||||
}
|
||||
|
||||
if (Number(job.rescan_requested) === 1) {
|
||||
await trx(TABLE)
|
||||
.where({
|
||||
id,
|
||||
status: 'processing',
|
||||
})
|
||||
.update({
|
||||
status: 'pending',
|
||||
rescan_requested: 0,
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
last_error: message,
|
||||
updated_at: trx.fn.now(),
|
||||
});
|
||||
|
||||
return {
|
||||
updated: true,
|
||||
status: 'pending',
|
||||
requeued: true,
|
||||
};
|
||||
}
|
||||
|
||||
await trx(TABLE)
|
||||
.where({
|
||||
id,
|
||||
status: 'processing',
|
||||
})
|
||||
.update({
|
||||
status: 'error',
|
||||
last_error: message,
|
||||
completed_at: trx.fn.now(),
|
||||
updated_at: trx.fn.now(),
|
||||
});
|
||||
|
||||
return {
|
||||
updated: true,
|
||||
status: 'error',
|
||||
requeued: false,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function resetStaleActivityIngestJobs(
|
||||
db,
|
||||
staleMinutes = 30
|
||||
) {
|
||||
await ensureActivityIngestQueue(db);
|
||||
|
||||
const cutoff = new Date(
|
||||
Date.now() - staleMinutes * 60 * 1000
|
||||
)
|
||||
.toISOString()
|
||||
.replace('T', ' ')
|
||||
.replace(/\.\d{3}Z$/, '');
|
||||
|
||||
return withSqliteBusyRetry(
|
||||
'activity queue stale reset',
|
||||
() => db(TABLE)
|
||||
.where({ status: 'processing' })
|
||||
.whereNotNull('started_at')
|
||||
.where('started_at', '<', cutoff)
|
||||
.update({
|
||||
status: 'pending',
|
||||
rescan_requested: 0,
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
last_error:
|
||||
'Recuperato dopo interruzione del worker',
|
||||
updated_at: db.fn.now(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function getActivityIngestQueueCounts(db) {
|
||||
await ensureActivityIngestQueue(db);
|
||||
|
||||
const rows = await db(TABLE)
|
||||
.select('status')
|
||||
.count({ total: '*' })
|
||||
.groupBy('status');
|
||||
|
||||
const counts = {
|
||||
pending: 0,
|
||||
processing: 0,
|
||||
done: 0,
|
||||
error: 0,
|
||||
};
|
||||
|
||||
for (const row of rows) {
|
||||
if (Object.hasOwn(counts, row.status)) {
|
||||
counts[row.status] = Number(row.total);
|
||||
}
|
||||
}
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ensureActivityIngestQueue,
|
||||
enqueueActivityIngestJob,
|
||||
enqueueActivityIngestJobs,
|
||||
claimNextActivityIngestJob,
|
||||
completeActivityIngestJob,
|
||||
failActivityIngestJob,
|
||||
resetStaleActivityIngestJobs,
|
||||
getActivityIngestQueueCounts,
|
||||
};
|
||||
420
api_v1/scanner/activityIngestWorker.js
Normal file
420
api_v1/scanner/activityIngestWorker.js
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs/promises');
|
||||
|
||||
const {
|
||||
WEB_ROOT,
|
||||
SUPPORTED_ACTIVITY_EXTS,
|
||||
SEND_PHOTOS,
|
||||
BASE_URL,
|
||||
} = require('../config');
|
||||
|
||||
const scanActivitySingle =
|
||||
require('./scanActivitySingle');
|
||||
|
||||
const postWithAuth =
|
||||
require('./postWithAuth');
|
||||
|
||||
const { sha256 } = require('./utils');
|
||||
|
||||
const {
|
||||
enqueueServerEvent,
|
||||
} = require('../../db/serverEventOutbox');
|
||||
|
||||
const {
|
||||
ensureActivityIngestQueue,
|
||||
claimNextActivityIngestJob,
|
||||
enqueueActivityIngestJob,
|
||||
completeActivityIngestJob,
|
||||
failActivityIngestJob,
|
||||
resetStaleActivityIngestJobs,
|
||||
} = require('./activityIngestQueue');
|
||||
|
||||
function sameVersion(stat, job) {
|
||||
return (
|
||||
Math.trunc(stat.mtimeMs) ===
|
||||
Math.trunc(Number(job.source_mtime_ms)) &&
|
||||
stat.size ===
|
||||
Number(job.source_size_bytes)
|
||||
);
|
||||
}
|
||||
|
||||
function isInside(parent, child) {
|
||||
const relative = path.relative(parent, child);
|
||||
|
||||
return (
|
||||
relative === '' ||
|
||||
(
|
||||
!relative.startsWith(`..${path.sep}`) &&
|
||||
relative !== '..' &&
|
||||
!path.isAbsolute(relative)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveActivityJobFile(job) {
|
||||
for (const value of [
|
||||
job.user,
|
||||
job.cartella,
|
||||
job.rel_path,
|
||||
]) {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!value.trim()
|
||||
) {
|
||||
throw new Error(
|
||||
'Percorso activity job non valido'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
path.isAbsolute(job.rel_path) ||
|
||||
job.rel_path.includes('\\') ||
|
||||
job.rel_path
|
||||
.split('/')
|
||||
.some(part =>
|
||||
!part ||
|
||||
part === '.' ||
|
||||
part === '..'
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'Percorso relativo activity non sicuro'
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
job.user === '.' ||
|
||||
job.user === '..' ||
|
||||
job.cartella === '.' ||
|
||||
job.cartella === '..' ||
|
||||
job.user.includes('/') ||
|
||||
job.user.includes('\\') ||
|
||||
job.cartella.includes('/') ||
|
||||
job.cartella.includes('\\')
|
||||
) {
|
||||
throw new Error(
|
||||
'Utente o cartella activity non validi'
|
||||
);
|
||||
}
|
||||
|
||||
const activitiesRoot = path.resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
WEB_ROOT,
|
||||
'activities'
|
||||
);
|
||||
|
||||
const originalRoot = path.resolve(
|
||||
activitiesRoot,
|
||||
job.user,
|
||||
'original',
|
||||
job.cartella
|
||||
);
|
||||
|
||||
const absPath = path.resolve(
|
||||
originalRoot,
|
||||
...job.rel_path.split('/')
|
||||
);
|
||||
|
||||
if (!isInside(originalRoot, absPath)) {
|
||||
throw new Error(
|
||||
'Activity file esce dalla cartella prevista'
|
||||
);
|
||||
}
|
||||
|
||||
const realRoot = await fs.realpath(
|
||||
originalRoot
|
||||
);
|
||||
|
||||
const realFile = await fs.realpath(
|
||||
absPath
|
||||
);
|
||||
|
||||
if (!isInside(realRoot, realFile)) {
|
||||
throw new Error(
|
||||
'Activity file risolve fuori dalla cartella prevista'
|
||||
);
|
||||
}
|
||||
|
||||
const ext = path
|
||||
.extname(absPath)
|
||||
.toLowerCase();
|
||||
|
||||
if (!SUPPORTED_ACTIVITY_EXTS.has(ext)) {
|
||||
throw new Error(
|
||||
`Estensione activity non supportata: ${ext}`
|
||||
);
|
||||
}
|
||||
|
||||
const stat = await fs.stat(realFile);
|
||||
|
||||
if (!stat.isFile()) {
|
||||
throw new Error(
|
||||
'Activity path non e un file regolare'
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id: sha256(
|
||||
`${job.user}/${job.cartella}/${job.rel_path}`
|
||||
),
|
||||
user: job.user,
|
||||
cartella: job.cartella,
|
||||
file_name: path.basename(absPath),
|
||||
name: path.basename(absPath),
|
||||
relPath: job.rel_path,
|
||||
absPath: realFile,
|
||||
ext,
|
||||
stat,
|
||||
path:
|
||||
`/activities/${job.user}/original/` +
|
||||
`${job.cartella}/${job.rel_path}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function runOneJob(db) {
|
||||
await ensureActivityIngestQueue(db);
|
||||
|
||||
const job =
|
||||
await claimNextActivityIngestJob(db);
|
||||
|
||||
if (!job) {
|
||||
return { processed: false };
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[ActivityIngest] Avvio job ${job.id}: ` +
|
||||
`${job.rel_path}`
|
||||
);
|
||||
|
||||
try {
|
||||
const file =
|
||||
await resolveActivityJobFile(job);
|
||||
|
||||
/*
|
||||
* Se il file e' gia cambiato prima di iniziare
|
||||
* l'elaborazione, aggiorna la versione in queue.
|
||||
*/
|
||||
if (!sameVersion(file.stat, job)) {
|
||||
await enqueueActivityIngestJob(db, {
|
||||
user: job.user,
|
||||
cartella: job.cartella,
|
||||
relPath: job.rel_path,
|
||||
mtimeMs: file.stat.mtimeMs,
|
||||
sizeBytes: file.stat.size,
|
||||
});
|
||||
|
||||
const result =
|
||||
await completeActivityIngestJob(
|
||||
db,
|
||||
job.id
|
||||
);
|
||||
|
||||
return {
|
||||
processed: true,
|
||||
status: result.status,
|
||||
requeued: Boolean(result.requeued),
|
||||
};
|
||||
}
|
||||
|
||||
// Memorizza lo stato precedente per distinguere inserimenti e aggiornamenti.
|
||||
const previousActivity = await db('activities')
|
||||
.select('id', 'deleted_at')
|
||||
.where({ id: file.id })
|
||||
.first();
|
||||
|
||||
const newActivities = [];
|
||||
|
||||
const scannedActivity =
|
||||
await scanActivitySingle(
|
||||
db,
|
||||
job.user,
|
||||
job.cartella,
|
||||
file,
|
||||
newActivities
|
||||
);
|
||||
|
||||
/*
|
||||
* Ricontrolla il file dopo il parsing:
|
||||
* se e' cambiato mentre veniva elaborato,
|
||||
* richiede una seconda scansione.
|
||||
*/
|
||||
/*
|
||||
* Sincronizzazione verso BASE_URL.
|
||||
*
|
||||
* La semantica resta quella del vecchio scanActivity:
|
||||
* un errore remoto viene registrato ma NON rende fallito
|
||||
* l'ingest locale.
|
||||
*/
|
||||
if (
|
||||
scannedActivity &&
|
||||
SEND_PHOTOS &&
|
||||
BASE_URL
|
||||
) {
|
||||
try {
|
||||
scannedActivity.user = job.user;
|
||||
|
||||
await postWithAuth(
|
||||
`${BASE_URL}/activities`,
|
||||
scannedActivity
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[ActivityIngest] Activity ${scannedActivity.id} ` +
|
||||
`inviata al server remoto`
|
||||
);
|
||||
} catch (remoteError) {
|
||||
console.error(
|
||||
`[ActivityIngest] Invio remoto fallito ` +
|
||||
`${scannedActivity.id}:`,
|
||||
remoteError.message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const statAfter =
|
||||
await fs.stat(file.absPath);
|
||||
|
||||
if (
|
||||
Math.trunc(statAfter.mtimeMs) !==
|
||||
Math.trunc(file.stat.mtimeMs) ||
|
||||
statAfter.size !== file.stat.size
|
||||
) {
|
||||
await enqueueActivityIngestJob(db, {
|
||||
user: job.user,
|
||||
cartella: job.cartella,
|
||||
relPath: job.rel_path,
|
||||
mtimeMs: statAfter.mtimeMs,
|
||||
sizeBytes: statAfter.size,
|
||||
});
|
||||
}
|
||||
|
||||
const result =
|
||||
await completeActivityIngestJob(
|
||||
db,
|
||||
job.id
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[ActivityIngest] Job ${job.id} ` +
|
||||
`completato; stato: ${result.status}`
|
||||
);
|
||||
|
||||
// Notifica la galleria solo dopo il completamento effettivo dell'ingest.
|
||||
if (
|
||||
scannedActivity &&
|
||||
scannedActivity.id &&
|
||||
!result.requeued &&
|
||||
result.status === 'done'
|
||||
) {
|
||||
await enqueueServerEvent(db, {
|
||||
channel: 'activity',
|
||||
user: job.user,
|
||||
payload: {
|
||||
type:
|
||||
previousActivity && !previousActivity.deleted_at
|
||||
? 'updated'
|
||||
: 'added',
|
||||
id: scannedActivity.id,
|
||||
entity: 'activity',
|
||||
mode: 'activities',
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[ActivityIngest] Evento gallery accodato ` +
|
||||
`activity=${scannedActivity.id}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
processed: true,
|
||||
status: result.status,
|
||||
requeued: Boolean(result.requeued),
|
||||
activityId:
|
||||
scannedActivity?.id || file.id,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[ActivityIngest] Errore job ${job.id}:`,
|
||||
error.message
|
||||
);
|
||||
|
||||
try {
|
||||
await failActivityIngestJob(
|
||||
db,
|
||||
job.id,
|
||||
error
|
||||
);
|
||||
} catch (queueError) {
|
||||
console.error(
|
||||
'[ActivityIngest] Errore aggiornando queue:',
|
||||
queueError.message
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
processed: true,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function runWorker(
|
||||
db,
|
||||
options = {}
|
||||
) {
|
||||
const pollMs = Math.max(
|
||||
250,
|
||||
Number(options.pollMs) || 2000
|
||||
);
|
||||
|
||||
const staleMinutes = Math.max(
|
||||
1,
|
||||
Number(options.staleMinutes) || 30
|
||||
);
|
||||
|
||||
await ensureActivityIngestQueue(db);
|
||||
|
||||
await resetStaleActivityIngestJobs(
|
||||
db,
|
||||
staleMinutes
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[ActivityIngest] Worker avviato; ` +
|
||||
`polling ${pollMs} ms`
|
||||
);
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const result = await runOneJob(db);
|
||||
|
||||
if (!result.processed) {
|
||||
await new Promise(
|
||||
resolve => setTimeout(resolve, pollMs)
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[ActivityIngest] Errore worker:',
|
||||
error
|
||||
);
|
||||
|
||||
await new Promise(
|
||||
resolve => setTimeout(resolve, pollMs)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
runOneJob,
|
||||
runWorker,
|
||||
resolveActivityJobFile,
|
||||
sameVersion,
|
||||
};
|
||||
57
api_v1/scanner/activityOrphanCleanup.js
Normal file
57
api_v1/scanner/activityOrphanCleanup.js
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
const RETENTION_DAYS = 30;
|
||||
|
||||
module.exports = function createActivityOrphanCleanup(db) {
|
||||
async function deleteFromDB(id, user) {
|
||||
const now = new Date();
|
||||
const nowIso = now.toISOString();
|
||||
|
||||
const rec = await db('activities')
|
||||
.where({ id, user })
|
||||
.first();
|
||||
|
||||
if (!rec) return false;
|
||||
|
||||
// Primo rilevamento: soft delete.
|
||||
if (!rec.deleted_at) {
|
||||
await db('activities')
|
||||
.where({ id, user })
|
||||
.update({
|
||||
deleted_at: nowIso,
|
||||
updated_at: nowIso,
|
||||
});
|
||||
|
||||
console.log(`🟡 Soft delete attività orfana: ${id}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Hard delete solo dopo il periodo di retention.
|
||||
const deletedAt = new Date(rec.deleted_at);
|
||||
const ageMs = now.getTime() - deletedAt.getTime();
|
||||
const retentionMs = RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||
|
||||
if (!Number.isFinite(deletedAt.getTime()) || ageMs <= retentionMs) {
|
||||
console.log(`🟡 Attività in retention: ${id}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx('activities')
|
||||
.where({ id, user })
|
||||
.del();
|
||||
|
||||
await trx('deleted_hard_activities')
|
||||
.insert({
|
||||
id,
|
||||
user,
|
||||
deleted_at: nowIso,
|
||||
})
|
||||
.onConflict('id')
|
||||
.merge();
|
||||
});
|
||||
|
||||
console.log(`🔴 Hard delete attività orfana: ${id}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
return { deleteFromDB };
|
||||
};
|
||||
92
api_v1/scanner/activityScanProgress.js
Normal file
92
api_v1/scanner/activityScanProgress.js
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
'use strict';
|
||||
|
||||
const activityIngestQueue = require('./activityIngestQueue');
|
||||
|
||||
let epoch = null;
|
||||
|
||||
async function beginGlobalActivityScan(db) {
|
||||
if (!epoch || !epoch.active) {
|
||||
const created = {
|
||||
active: true,
|
||||
baselineDone: 0,
|
||||
enumerationsInProgress: 0,
|
||||
initPromise: null,
|
||||
};
|
||||
|
||||
epoch = created;
|
||||
created.initPromise = activityIngestQueue
|
||||
.getActivityIngestQueueCounts(db)
|
||||
.then(counts => {
|
||||
created.baselineDone = Number(counts.done || 0);
|
||||
})
|
||||
.catch(err => {
|
||||
if (epoch === created) created.active = false;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
const current = epoch;
|
||||
current.enumerationsInProgress++;
|
||||
|
||||
try {
|
||||
await current.initPromise;
|
||||
} catch (err) {
|
||||
current.enumerationsInProgress = Math.max(
|
||||
0, current.enumerationsInProgress - 1
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function finishGlobalActivityScan() {
|
||||
if (epoch) {
|
||||
epoch.enumerationsInProgress = Math.max(
|
||||
0, epoch.enumerationsInProgress - 1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function getGlobalActivityScanStatus(db) {
|
||||
const counts = await activityIngestQueue.getActivityIngestQueueCounts(db);
|
||||
const pending = Number(counts.pending || 0);
|
||||
const processing = Number(counts.processing || 0);
|
||||
const done = Number(counts.done || 0);
|
||||
const errors = Number(counts.error || 0);
|
||||
const remaining = pending + processing;
|
||||
const totalCurrent = pending + processing + done + errors;
|
||||
|
||||
let current;
|
||||
let total;
|
||||
let percent;
|
||||
let baselineDone = done;
|
||||
|
||||
if (epoch && epoch.active) {
|
||||
baselineDone = epoch.baselineDone;
|
||||
total = Math.max(0, totalCurrent - baselineDone);
|
||||
current = Math.max(0, (done - baselineDone) + errors);
|
||||
percent = total <= 0
|
||||
? 100
|
||||
: Math.max(0, Math.min(100, Math.round(current / total * 100)));
|
||||
|
||||
// Chiude il ciclo quando tutte le scansioni hanno terminato
|
||||
// l'accodamento e non restano job pending o processing.
|
||||
if (epoch.enumerationsInProgress === 0 && remaining === 0) {
|
||||
epoch.active = false;
|
||||
}
|
||||
} else {
|
||||
total = totalCurrent;
|
||||
current = totalCurrent;
|
||||
percent = 100;
|
||||
}
|
||||
|
||||
return {
|
||||
pending, processing, done, errors, remaining,
|
||||
current, total, percent, baselineDone,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
beginGlobalActivityScan,
|
||||
finishGlobalActivityScan,
|
||||
getGlobalActivityScanStatus,
|
||||
};
|
||||
64
api_v1/scanner/debugVideoDates.js
Normal file
64
api_v1/scanner/debugVideoDates.js
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// api_v1/scanner/debugVideoDates.js
|
||||
const path = require('path');
|
||||
const { probeVideo } = require('./video');
|
||||
|
||||
async function debugVideo(absPath) {
|
||||
console.log("🎥 File:", absPath);
|
||||
|
||||
const info = await probeVideo(absPath);
|
||||
|
||||
console.log("\n=== RAW probeVideo(info) ===\n");
|
||||
console.dir(info, { depth: 6 });
|
||||
|
||||
// Estrazione campi data come nello scanner
|
||||
const formatTags = info.format?.tags || {};
|
||||
const stream0Tags = info.streams?.[0]?.tags || {};
|
||||
const stream1Tags = info.streams?.[1]?.tags || {};
|
||||
|
||||
const creationFormat = formatTags.creation_time || null;
|
||||
const creationStream0 = stream0Tags.creation_time || null;
|
||||
const creationStream1 = stream1Tags.creation_time || null;
|
||||
|
||||
console.log("\n=== DATE CANDIDATE ===");
|
||||
console.log("format.tags.creation_time :", creationFormat);
|
||||
console.log("streams[0].tags.creation_time:", creationStream0);
|
||||
console.log("streams[1].tags.creation_time:", creationStream1);
|
||||
|
||||
// Simulazione logica attuale dello scanner (ma esplicita)
|
||||
let videoDate =
|
||||
creationFormat ||
|
||||
creationStream0 ||
|
||||
creationStream1 ||
|
||||
null;
|
||||
|
||||
console.log("\nScelta videoDate (prima di qualsiasi conversione):", videoDate);
|
||||
|
||||
if (videoDate) {
|
||||
// Variante 1: SENZA conversione (quella che ti ho suggerito)
|
||||
const takenAtRaw = videoDate;
|
||||
|
||||
// Variante 2: CON conversione (quella che ti sballa il giorno)
|
||||
const takenAtIso = new Date(videoDate).toISOString();
|
||||
|
||||
console.log("\n=== CONFRONTO ===");
|
||||
console.log("takenAtRaw (usato così com'è) :", takenAtRaw);
|
||||
console.log("takenAtIso (new Date().toISOString):", takenAtIso);
|
||||
} else {
|
||||
console.log("\n⚠ Nessuna data trovata nei tag video.");
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const arg = process.argv[2];
|
||||
if (!arg) {
|
||||
console.error("Uso: node debugVideoDates.js /percorso/al/video.mp4");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const absPath = path.resolve(arg);
|
||||
await debugVideo(absPath);
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error("❌ Errore debugVideoDates:", err);
|
||||
});
|
||||
73
api_v1/scanner/deleteFolder.js
Normal file
73
api_v1/scanner/deleteFolder.js
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
// api_v1/scanner/deleteFolder.js
|
||||
const path = require('path');
|
||||
const fsp = require('fs/promises');
|
||||
const { WEB_ROOT } = require('../config');
|
||||
|
||||
module.exports = function createDeleteFolderFunctions(db, RETENTION_DAYS = 30) {
|
||||
|
||||
const createCleanupFunctions = require('./orphanCleanup');
|
||||
const { deleteFromDB, deleteThumbsById } = createCleanupFunctions(db, RETENTION_DAYS);
|
||||
|
||||
/**
|
||||
* Cancella una cartella per un utente:
|
||||
* - soft delete immediato di tutte le foto
|
||||
* - hard delete se retention scaduta
|
||||
* - registra hard delete in deleted_hard
|
||||
* - elimina thumbs solo per hard delete
|
||||
*/
|
||||
async function deleteFolderForUser(userName, folderName) {
|
||||
|
||||
// 1) Recupera tutti gli ID nel DB
|
||||
const rows = await db('photos')
|
||||
.where({ user: userName, cartella: folderName })
|
||||
.select('id');
|
||||
|
||||
let softCount = 0;
|
||||
let hardCount = 0;
|
||||
|
||||
// 2) Soft delete / Hard delete per ogni foto
|
||||
for (const r of rows) {
|
||||
const id = r.id;
|
||||
|
||||
const result = await deleteFromDB(id, userName);
|
||||
|
||||
if (result === true) {
|
||||
// deleteFromDB decide se è soft o hard
|
||||
// possiamo verificare se la foto esiste ancora nel DB
|
||||
const stillExists = await db("photos")
|
||||
.where({ id, user: userName })
|
||||
.first();
|
||||
|
||||
if (stillExists) {
|
||||
softCount++;
|
||||
} else {
|
||||
hardCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Cancella la cartella thumbs residua (solo se hard delete totale)
|
||||
const thumbsDir = path.join(
|
||||
WEB_ROOT,
|
||||
userName,
|
||||
"thumbs",
|
||||
folderName
|
||||
);
|
||||
|
||||
try {
|
||||
await fsp.rm(thumbsDir, { recursive: true, force: true });
|
||||
console.log(`🧹 Rimossa cartella thumbs residua: ${thumbsDir}`);
|
||||
} catch (err) {
|
||||
console.log(`⚠️ Errore rimozione thumbs dir: ${thumbsDir}`, err);
|
||||
}
|
||||
|
||||
return {
|
||||
totalPhotos: rows.length,
|
||||
softDeleted: softCount,
|
||||
hardDeleted: hardCount,
|
||||
removedThumbsDir: thumbsDir
|
||||
};
|
||||
}
|
||||
|
||||
return { deleteFolderForUser };
|
||||
};
|
||||
19
api_v1/scanner/deleteWithAuth.js
Normal file
19
api_v1/scanner/deleteWithAuth.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// scanner/deleteWithAuth.js
|
||||
const { API_KEY } = require('../config');
|
||||
|
||||
module.exports = async function deleteWithAuth(url) {
|
||||
// import dinamico compatibile con CommonJS
|
||||
const fetch = (await import('node-fetch')).default;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'x-api-key': API_KEY,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`DELETE failed: ${res.status}`);
|
||||
}
|
||||
};
|
||||
81
api_v1/scanner/elevation.js
Normal file
81
api_v1/scanner/elevation.js
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
// api_v1/scanner/elevation.js
|
||||
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Provider 1: OpenElevation
|
||||
// ---------------------------------------------------------
|
||||
async function tryOpenElevation(lat, lon) {
|
||||
try {
|
||||
const url = `https://api.open-elevation.com/api/v1/lookup?locations=${lat},${lon}`;
|
||||
const res = await fetch(url, { timeout: 5000 });
|
||||
|
||||
if (!res.ok) {
|
||||
console.log("OpenElevation status:", res.status);
|
||||
return null;
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
|
||||
// Se non è JSON → errore HTML → fallback
|
||||
if (!text.startsWith('{')) {
|
||||
console.log("OpenElevation non-JSON:", text.slice(0, 60));
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = JSON.parse(text);
|
||||
return data?.results?.[0]?.elevation ?? null;
|
||||
|
||||
} catch (err) {
|
||||
console.log("Errore OpenElevation:", err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Provider 2: OpenTopoData (fallback)
|
||||
// ---------------------------------------------------------
|
||||
async function tryOpenTopoData(lat, lon) {
|
||||
try {
|
||||
const url = `https://api.opentopodata.org/v1/eudem25m?locations=${lat},${lon}`;
|
||||
const res = await fetch(url, { timeout: 5000 });
|
||||
|
||||
if (!res.ok) {
|
||||
console.log("OpenTopoData status:", res.status);
|
||||
return null;
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
|
||||
if (!text.startsWith('{')) {
|
||||
console.log("OpenTopoData non-JSON:", text.slice(0, 60));
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = JSON.parse(text);
|
||||
return data?.results?.[0]?.elevation ?? null;
|
||||
|
||||
} catch (err) {
|
||||
console.log("Errore OpenTopoData:", err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Funzione principale con fallback
|
||||
// ---------------------------------------------------------
|
||||
async function getElevation(lat, lon) {
|
||||
// 1️⃣ Prova OpenElevation
|
||||
const elev1 = await tryOpenElevation(lat, lon);
|
||||
if (elev1 !== null) return elev1;
|
||||
|
||||
console.log("⚠️ OpenElevation fallito → uso OpenTopoData");
|
||||
|
||||
// 2️⃣ Prova OpenTopoData
|
||||
const elev2 = await tryOpenTopoData(lat, lon);
|
||||
if (elev2 !== null) return elev2;
|
||||
|
||||
console.log("❌ Nessun provider di elevazione disponibile");
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = getElevation;
|
||||
492
api_v1/scanner/faceScanWorker.js
Normal file
492
api_v1/scanner/faceScanWorker.js
Normal file
|
|
@ -0,0 +1,492 @@
|
|||
const db = require('../../db/knex');
|
||||
|
||||
const {
|
||||
scanPhotoFacesById,
|
||||
} = require('./faceScanner');
|
||||
|
||||
function serializeError(error) {
|
||||
const value =
|
||||
error?.stack
|
||||
|| error?.message
|
||||
|| String(error);
|
||||
|
||||
return String(value).slice(0, 16000);
|
||||
}
|
||||
|
||||
async function claimNextPendingJob() {
|
||||
return db.transaction(async (trx) => {
|
||||
const job = await trx('face_scan_jobs')
|
||||
.where({ status: 'pending' })
|
||||
.orderBy('created_at', 'asc')
|
||||
.orderBy('photo_id', 'asc')
|
||||
.first();
|
||||
|
||||
if (!job) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const updated = await trx('face_scan_jobs')
|
||||
.where({
|
||||
photo_id: job.photo_id,
|
||||
status: 'pending',
|
||||
})
|
||||
.update({
|
||||
status: 'processing',
|
||||
attempts: trx.raw('attempts + 1'),
|
||||
faces_detected: null,
|
||||
execution_ms: null,
|
||||
applied_rotation: null,
|
||||
last_error: null,
|
||||
started_at: trx.fn.now(),
|
||||
completed_at: null,
|
||||
updated_at: trx.fn.now(),
|
||||
});
|
||||
|
||||
if (updated !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trx('face_scan_jobs')
|
||||
.where({ photo_id: job.photo_id })
|
||||
.first();
|
||||
});
|
||||
}
|
||||
|
||||
async function completeJob(photoId, result) {
|
||||
return db.transaction(async (trx) => {
|
||||
const job = await trx('face_scan_jobs')
|
||||
.where({
|
||||
photo_id: photoId,
|
||||
status: 'processing',
|
||||
})
|
||||
.first();
|
||||
|
||||
if (!job) {
|
||||
throw new Error(
|
||||
`Job processing non trovato: photo_id=${photoId}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (Number(job.rescan_requested) === 1) {
|
||||
const updated = await trx('face_scan_jobs')
|
||||
.where({
|
||||
photo_id: photoId,
|
||||
status: 'processing',
|
||||
})
|
||||
.update({
|
||||
status: 'pending',
|
||||
rescan_requested: 0,
|
||||
faces_detected: null,
|
||||
execution_ms: null,
|
||||
applied_rotation: null,
|
||||
last_error: null,
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
updated_at: trx.fn.now(),
|
||||
});
|
||||
|
||||
if (updated !== 1) {
|
||||
throw new Error(
|
||||
`Impossibile riaccodare il job photo_id=${photoId}`,
|
||||
);
|
||||
}
|
||||
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
const updated = await trx('face_scan_jobs')
|
||||
.where({
|
||||
photo_id: photoId,
|
||||
status: 'processing',
|
||||
})
|
||||
.update({
|
||||
status: 'done',
|
||||
rescan_requested: 0,
|
||||
faces_detected: result.faces_detected,
|
||||
execution_ms: result.execution_ms,
|
||||
applied_rotation: result.applied_rotation,
|
||||
last_error: null,
|
||||
completed_at: trx.fn.now(),
|
||||
updated_at: trx.fn.now(),
|
||||
});
|
||||
|
||||
if (updated !== 1) {
|
||||
throw new Error(
|
||||
`Impossibile completare il job photo_id=${photoId}`,
|
||||
);
|
||||
}
|
||||
|
||||
return 'done';
|
||||
});
|
||||
}
|
||||
|
||||
async function failJob(photoId, error) {
|
||||
return db.transaction(async (trx) => {
|
||||
const job = await trx('face_scan_jobs')
|
||||
.where({
|
||||
photo_id: photoId,
|
||||
status: 'processing',
|
||||
})
|
||||
.first();
|
||||
|
||||
if (!job) {
|
||||
console.error(
|
||||
'[faceScanWorker] job processing non trovato:',
|
||||
photoId,
|
||||
);
|
||||
|
||||
return 'missing';
|
||||
}
|
||||
|
||||
if (Number(job.rescan_requested) === 1) {
|
||||
const updated = await trx('face_scan_jobs')
|
||||
.where({
|
||||
photo_id: photoId,
|
||||
status: 'processing',
|
||||
})
|
||||
.update({
|
||||
status: 'pending',
|
||||
rescan_requested: 0,
|
||||
faces_detected: null,
|
||||
execution_ms: null,
|
||||
applied_rotation: null,
|
||||
last_error: null,
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
updated_at: trx.fn.now(),
|
||||
});
|
||||
|
||||
if (updated !== 1) {
|
||||
throw new Error(
|
||||
`Impossibile riaccodare il job photo_id=${photoId}`,
|
||||
);
|
||||
}
|
||||
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
const updated = await trx('face_scan_jobs')
|
||||
.where({
|
||||
photo_id: photoId,
|
||||
status: 'processing',
|
||||
})
|
||||
.update({
|
||||
status: 'error',
|
||||
rescan_requested: 0,
|
||||
last_error: serializeError(error),
|
||||
completed_at: trx.fn.now(),
|
||||
updated_at: trx.fn.now(),
|
||||
});
|
||||
|
||||
if (updated !== 1) {
|
||||
console.error(
|
||||
'[faceScanWorker] impossibile registrare errore:',
|
||||
photoId,
|
||||
);
|
||||
|
||||
return 'missing';
|
||||
}
|
||||
|
||||
return 'error';
|
||||
});
|
||||
}
|
||||
|
||||
async function completeJobWithRetry(photoId, result) {
|
||||
const delays = [0, 250, 500, 1000, 2000];
|
||||
|
||||
for (let attempt = 0; attempt < delays.length; attempt += 1) {
|
||||
if (delays[attempt] > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delays[attempt]));
|
||||
}
|
||||
|
||||
try {
|
||||
return await completeJob(photoId, result);
|
||||
} catch (error) {
|
||||
const message = String(error?.message || error);
|
||||
const code = String(error?.code || '');
|
||||
const isBusy =
|
||||
code === 'SQLITE_BUSY'
|
||||
|| code === 'SQLITE_LOCKED'
|
||||
|| message.includes('database is locked')
|
||||
|| message.includes('SQLITE_BUSY')
|
||||
|| message.includes('SQLITE_LOCKED');
|
||||
|
||||
if (!isBusy || attempt === delays.length - 1) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.warn('[faceScanWorker] SQLite occupato: nuovo tentativo di completamento', {
|
||||
photo_id: photoId,
|
||||
retry: attempt + 1,
|
||||
next_delay_ms: delays[attempt + 1],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Retry completamento esauriti');
|
||||
}
|
||||
|
||||
async function processClaimedJob(job) {
|
||||
if (!job || !job.photo_id) {
|
||||
throw new Error('Job non valido');
|
||||
}
|
||||
|
||||
const workerStartedAt = Date.now();
|
||||
|
||||
console.log(
|
||||
'[faceScanWorker] processing',
|
||||
{
|
||||
photo_id: job.photo_id,
|
||||
attempt: job.attempts,
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await scanPhotoFacesById(
|
||||
job.photo_id,
|
||||
);
|
||||
|
||||
let finalStatus;
|
||||
|
||||
try {
|
||||
finalStatus = await completeJobWithRetry(
|
||||
job.photo_id,
|
||||
result,
|
||||
);
|
||||
} catch (writeError) {
|
||||
const message = String(writeError?.message || writeError);
|
||||
const code = String(writeError?.code || '');
|
||||
const isBusy =
|
||||
code === 'SQLITE_BUSY'
|
||||
|| code === 'SQLITE_LOCKED'
|
||||
|| message.includes('database is locked')
|
||||
|| message.includes('SQLITE_BUSY')
|
||||
|| message.includes('SQLITE_LOCKED');
|
||||
|
||||
if (isBusy) {
|
||||
console.error('[faceScanWorker] impossibile salvare il risultato dopo i retry', {
|
||||
photo_id: job.photo_id,
|
||||
attempt: job.attempts,
|
||||
error: message,
|
||||
});
|
||||
|
||||
try {
|
||||
const updated = await db('face_scan_jobs')
|
||||
.where({
|
||||
photo_id: job.photo_id,
|
||||
status: 'processing',
|
||||
})
|
||||
.update({
|
||||
status: 'pending',
|
||||
last_error: 'Retry salvataggio risultato esauriti: ' + message,
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
|
||||
if (updated === 1) {
|
||||
return {
|
||||
status: 'pending',
|
||||
photo_id: job.photo_id,
|
||||
attempt: job.attempts,
|
||||
rescan_required: true,
|
||||
worker_ms: Date.now() - workerStartedAt,
|
||||
};
|
||||
}
|
||||
} catch (requeueError) {
|
||||
console.error('[faceScanWorker] impossibile riaccodare il job; sarà recuperato come obsoleto', {
|
||||
photo_id: job.photo_id,
|
||||
error: String(requeueError?.message || requeueError),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'processing',
|
||||
photo_id: job.photo_id,
|
||||
attempt: job.attempts,
|
||||
error: 'Salvataggio fallito; job recuperabile tramite reset stale',
|
||||
rescan_required: false,
|
||||
worker_ms: Date.now() - workerStartedAt,
|
||||
};
|
||||
}
|
||||
|
||||
throw writeError;
|
||||
}
|
||||
|
||||
const summary = {
|
||||
status: finalStatus,
|
||||
photo_id: job.photo_id,
|
||||
attempt: job.attempts,
|
||||
faces_detected:
|
||||
finalStatus === 'done'
|
||||
? result.faces_detected
|
||||
: null,
|
||||
applied_rotation:
|
||||
finalStatus === 'done'
|
||||
? result.applied_rotation
|
||||
: null,
|
||||
execution_ms:
|
||||
finalStatus === 'done'
|
||||
? result.execution_ms
|
||||
: null,
|
||||
rescan_required:
|
||||
finalStatus === 'pending',
|
||||
worker_ms: Date.now() - workerStartedAt,
|
||||
};
|
||||
|
||||
console.log(
|
||||
'[faceScanWorker] done',
|
||||
summary,
|
||||
);
|
||||
|
||||
return summary;
|
||||
} catch (error) {
|
||||
const finalStatus = await failJob(
|
||||
job.photo_id,
|
||||
error,
|
||||
);
|
||||
|
||||
const summary = {
|
||||
status: finalStatus,
|
||||
photo_id: job.photo_id,
|
||||
attempt: job.attempts,
|
||||
error:
|
||||
finalStatus === 'error'
|
||||
? serializeError(error)
|
||||
: null,
|
||||
rescan_required:
|
||||
finalStatus === 'pending',
|
||||
worker_ms: Date.now() - workerStartedAt,
|
||||
};
|
||||
|
||||
console.error(
|
||||
'[faceScanWorker] error',
|
||||
summary,
|
||||
);
|
||||
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
|
||||
async function processNextJob() {
|
||||
const job = await claimNextPendingJob();
|
||||
|
||||
if (!job) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return processClaimedJob(job);
|
||||
}
|
||||
|
||||
async function runWorker(options = {}) {
|
||||
const maxJobs =
|
||||
options.maxJobs == null
|
||||
? null
|
||||
: Number(options.maxJobs);
|
||||
|
||||
if (
|
||||
maxJobs !== null
|
||||
&& (
|
||||
!Number.isInteger(maxJobs)
|
||||
|| maxJobs <= 0
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'maxJobs deve essere un intero positivo',
|
||||
);
|
||||
}
|
||||
|
||||
const shouldStop =
|
||||
typeof options.shouldStop === 'function'
|
||||
? options.shouldStop
|
||||
: () => false;
|
||||
|
||||
const results = [];
|
||||
|
||||
while (
|
||||
!shouldStop()
|
||||
&& (
|
||||
maxJobs === null
|
||||
|| results.length < maxJobs
|
||||
)
|
||||
) {
|
||||
const result = await processNextJob();
|
||||
|
||||
if (!result) {
|
||||
break;
|
||||
}
|
||||
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
const succeeded = results.filter(
|
||||
(result) => result.status === 'done',
|
||||
).length;
|
||||
|
||||
const failed = results.filter(
|
||||
(result) => result.status === 'error',
|
||||
).length;
|
||||
|
||||
const rescheduled = results.filter(
|
||||
(result) => result.status === 'pending',
|
||||
).length;
|
||||
|
||||
return {
|
||||
jobs_processed: results.length,
|
||||
jobs_succeeded: succeeded,
|
||||
jobs_failed: failed,
|
||||
jobs_rescheduled: rescheduled,
|
||||
stopped: shouldStop(),
|
||||
results,
|
||||
};
|
||||
}
|
||||
|
||||
async function resetStaleProcessingJobs(
|
||||
staleMinutes = 30,
|
||||
) {
|
||||
const minutes = Number(staleMinutes);
|
||||
|
||||
if (
|
||||
!Number.isFinite(minutes)
|
||||
|| minutes <= 0
|
||||
) {
|
||||
throw new Error(
|
||||
'staleMinutes deve essere positivo',
|
||||
);
|
||||
}
|
||||
|
||||
const modifier =
|
||||
`-${Math.floor(minutes)} minutes`;
|
||||
|
||||
const updated = await db('face_scan_jobs')
|
||||
.where({ status: 'processing' })
|
||||
.where(
|
||||
'started_at',
|
||||
'<',
|
||||
db.raw(
|
||||
"datetime('now', ?)",
|
||||
[modifier],
|
||||
),
|
||||
)
|
||||
.update({
|
||||
status: 'pending',
|
||||
last_error:
|
||||
'Job recuperato dopo arresto del worker',
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
serializeError,
|
||||
claimNextPendingJob,
|
||||
completeJob,
|
||||
failJob,
|
||||
processClaimedJob,
|
||||
processNextJob,
|
||||
runWorker,
|
||||
resetStaleProcessingJobs,
|
||||
};
|
||||
519
api_v1/scanner/faceScanner.js
Normal file
519
api_v1/scanner/faceScanner.js
Normal file
|
|
@ -0,0 +1,519 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFile } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
|
||||
const db = require('../../db/knex');
|
||||
|
||||
const {
|
||||
resolveFilePathForEntryId,
|
||||
} = require('../../utils/resolveFilePath');
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const EMBEDDING_DIM = 128;
|
||||
const EMBEDDING_BYTES = EMBEDDING_DIM * 4;
|
||||
|
||||
const PROJECT_ROOT =
|
||||
path.resolve(__dirname, '../..');
|
||||
|
||||
function getFaceScanBinary() {
|
||||
if (process.env.FACE_SCAN_BINARY) {
|
||||
return path.resolve(
|
||||
process.env.FACE_SCAN_BINARY,
|
||||
);
|
||||
}
|
||||
|
||||
return path.join(
|
||||
PROJECT_ROOT,
|
||||
'bin',
|
||||
'face_scan',
|
||||
);
|
||||
}
|
||||
|
||||
function isFiniteNumber(value) {
|
||||
return (
|
||||
typeof value === 'number'
|
||||
&& Number.isFinite(value)
|
||||
);
|
||||
}
|
||||
|
||||
function validateImage(image) {
|
||||
if (!image || typeof image !== 'object') {
|
||||
throw new Error(
|
||||
'Risultato face_scan non valido: image mancante',
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!Number.isInteger(image.width)
|
||||
|| image.width <= 0
|
||||
) {
|
||||
throw new Error(
|
||||
`Larghezza immagine non valida: ${image.width}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!Number.isInteger(image.height)
|
||||
|| image.height <= 0
|
||||
) {
|
||||
throw new Error(
|
||||
`Altezza immagine non valida: ${image.height}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof image.path !== 'string') {
|
||||
throw new Error(
|
||||
'Percorso immagine non valido nel risultato face_scan',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateBoundingBox(bbox, faceIndex) {
|
||||
if (!bbox || typeof bbox !== 'object') {
|
||||
throw new Error(
|
||||
`Bounding box mancante per face_index=${faceIndex}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const key of ['x', 'y', 'width', 'height']) {
|
||||
if (!isFiniteNumber(bbox[key])) {
|
||||
throw new Error(
|
||||
`bbox.${key} non valido per face_index=${faceIndex}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (bbox.width < 0 || bbox.height < 0) {
|
||||
throw new Error(
|
||||
`Bounding box negativa per face_index=${faceIndex}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateLandmarks(landmarks, faceIndex) {
|
||||
if (!Array.isArray(landmarks)) {
|
||||
throw new Error(
|
||||
`Landmark non validi per face_index=${faceIndex}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (landmarks.length !== 5) {
|
||||
throw new Error(
|
||||
`Landmark non validi per face_index=${faceIndex}: `
|
||||
+ `attesi 5, ricevuti ${landmarks.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
landmarks.forEach((point, landmarkIndex) => {
|
||||
if (
|
||||
!Array.isArray(point)
|
||||
|| point.length !== 2
|
||||
|| !isFiniteNumber(point[0])
|
||||
|| !isFiniteNumber(point[1])
|
||||
) {
|
||||
throw new Error(
|
||||
`Landmark ${landmarkIndex} non valido `
|
||||
+ `per face_index=${faceIndex}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateEmbedding(embedding, faceIndex) {
|
||||
if (!Array.isArray(embedding)) {
|
||||
throw new Error(
|
||||
`Embedding non valido per face_index=${faceIndex}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (embedding.length !== EMBEDDING_DIM) {
|
||||
throw new Error(
|
||||
`Embedding non valido per face_index=${faceIndex}: `
|
||||
+ `attesi ${EMBEDDING_DIM}, `
|
||||
+ `ricevuti ${embedding.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (
|
||||
let embeddingIndex = 0;
|
||||
embeddingIndex < embedding.length;
|
||||
embeddingIndex += 1
|
||||
) {
|
||||
if (!isFiniteNumber(embedding[embeddingIndex])) {
|
||||
throw new Error(
|
||||
`Embedding non finito per face_index=${faceIndex}, `
|
||||
+ `indice=${embeddingIndex}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const normSquared = embedding.reduce(
|
||||
(sum, value) => sum + (value * value),
|
||||
0,
|
||||
);
|
||||
|
||||
const norm = Math.sqrt(normSquared);
|
||||
|
||||
if (
|
||||
!Number.isFinite(norm)
|
||||
|| Math.abs(norm - 1) > 0.001
|
||||
) {
|
||||
throw new Error(
|
||||
`Embedding non normalizzato per face_index=${faceIndex}: `
|
||||
+ `norm=${norm}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateFace(face, expectedIndex) {
|
||||
if (!face || typeof face !== 'object') {
|
||||
throw new Error(
|
||||
`Volto non valido all'indice ${expectedIndex}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!Number.isInteger(face.face_index)
|
||||
|| face.face_index !== expectedIndex
|
||||
) {
|
||||
throw new Error(
|
||||
`face_index non valido: atteso ${expectedIndex}, `
|
||||
+ `ricevuto ${face.face_index}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!isFiniteNumber(face.score)) {
|
||||
throw new Error(
|
||||
`Score non valido per face_index=${expectedIndex}`,
|
||||
);
|
||||
}
|
||||
|
||||
validateBoundingBox(face.bbox, expectedIndex);
|
||||
validateLandmarks(face.landmarks, expectedIndex);
|
||||
validateEmbedding(face.embedding, expectedIndex);
|
||||
}
|
||||
|
||||
function validateFaceScanResult(result) {
|
||||
if (!result || typeof result !== 'object') {
|
||||
throw new Error(
|
||||
'Risultato face_scan non valido',
|
||||
);
|
||||
}
|
||||
|
||||
validateImage(result.image);
|
||||
|
||||
if (!Array.isArray(result.faces)) {
|
||||
throw new Error(
|
||||
'Risultato face_scan non valido: faces non è un array',
|
||||
);
|
||||
}
|
||||
|
||||
result.faces.forEach((face, faceIndex) => {
|
||||
validateFace(face, faceIndex);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function embeddingToBlob(embedding) {
|
||||
if (
|
||||
!Array.isArray(embedding)
|
||||
|| embedding.length !== EMBEDDING_DIM
|
||||
) {
|
||||
throw new Error(
|
||||
`Embedding non valido: attesi ${EMBEDDING_DIM} valori`,
|
||||
);
|
||||
}
|
||||
|
||||
const blob = Buffer.allocUnsafe(EMBEDDING_BYTES);
|
||||
|
||||
embedding.forEach((value, index) => {
|
||||
if (!isFiniteNumber(value)) {
|
||||
throw new Error(
|
||||
`Valore embedding non valido all'indice ${index}`,
|
||||
);
|
||||
}
|
||||
|
||||
blob.writeFloatLE(value, index * 4);
|
||||
});
|
||||
|
||||
if (blob.length !== EMBEDDING_BYTES) {
|
||||
throw new Error(
|
||||
`Dimensione BLOB non valida: ${blob.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
return blob;
|
||||
}
|
||||
|
||||
async function verifyFaceScanBinary() {
|
||||
const binaryPath = getFaceScanBinary();
|
||||
|
||||
try {
|
||||
await fs.promises.access(
|
||||
binaryPath,
|
||||
fs.constants.X_OK,
|
||||
);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`face_scan non trovato o non eseguibile: ${binaryPath}`,
|
||||
);
|
||||
}
|
||||
|
||||
return binaryPath;
|
||||
}
|
||||
|
||||
async function runFaceScan(
|
||||
imagePath,
|
||||
rotationDegrees = 0,
|
||||
) {
|
||||
const binaryPath = await verifyFaceScanBinary();
|
||||
|
||||
const normalizedRotation =
|
||||
Number(rotationDegrees);
|
||||
|
||||
if (
|
||||
!Number.isInteger(normalizedRotation)
|
||||
|| ![0, 90, 180, 270].includes(normalizedRotation)
|
||||
) {
|
||||
throw new Error(
|
||||
`Rotazione foto non valida: ${rotationDegrees}`,
|
||||
);
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
|
||||
let stdout;
|
||||
let stderr;
|
||||
|
||||
try {
|
||||
const result = await execFileAsync(
|
||||
binaryPath,
|
||||
[
|
||||
'--rotation',
|
||||
String(normalizedRotation),
|
||||
imagePath,
|
||||
],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: Number(
|
||||
process.env.FACE_SCAN_TIMEOUT_MS || 300000,
|
||||
),
|
||||
maxBuffer: Number(
|
||||
process.env.FACE_SCAN_MAX_BUFFER
|
||||
|| 64 * 1024 * 1024,
|
||||
),
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
|
||||
stdout = result.stdout;
|
||||
stderr = result.stderr;
|
||||
} catch (error) {
|
||||
const diagnostics = [
|
||||
error.message,
|
||||
error.stderr,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
.trim();
|
||||
|
||||
throw new Error(
|
||||
`face_scan fallito per ${imagePath}`
|
||||
+ (diagnostics ? `\n${diagnostics}` : ''),
|
||||
);
|
||||
}
|
||||
|
||||
if (stderr && stderr.trim()) {
|
||||
console.warn(
|
||||
'[faceScanner] face_scan stderr:',
|
||||
stderr.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
if (!stdout || !stdout.trim()) {
|
||||
throw new Error(
|
||||
`face_scan non ha prodotto JSON per ${imagePath}`,
|
||||
);
|
||||
}
|
||||
|
||||
let parsed;
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(stdout);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`JSON face_scan non valido per ${imagePath}: `
|
||||
+ error.message,
|
||||
);
|
||||
}
|
||||
|
||||
validateFaceScanResult(parsed);
|
||||
|
||||
if (
|
||||
parsed.image.rotation !== normalizedRotation
|
||||
) {
|
||||
throw new Error(
|
||||
`Rotazione face_scan inattesa: richiesta `
|
||||
+ `${normalizedRotation}, ricevuta `
|
||||
+ `${parsed.image.rotation}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...parsed,
|
||||
execution_ms: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function faceToDatabaseRow(photoId, face) {
|
||||
const landmarks = face.landmarks;
|
||||
|
||||
return {
|
||||
id: `face:${photoId}:${face.face_index}`,
|
||||
photo_id: photoId,
|
||||
face_index: face.face_index,
|
||||
|
||||
x: face.bbox.x,
|
||||
y: face.bbox.y,
|
||||
width: face.bbox.width,
|
||||
height: face.bbox.height,
|
||||
|
||||
score: face.score,
|
||||
|
||||
landmark_0_x: landmarks[0][0],
|
||||
landmark_0_y: landmarks[0][1],
|
||||
|
||||
landmark_1_x: landmarks[1][0],
|
||||
landmark_1_y: landmarks[1][1],
|
||||
|
||||
landmark_2_x: landmarks[2][0],
|
||||
landmark_2_y: landmarks[2][1],
|
||||
|
||||
landmark_3_x: landmarks[3][0],
|
||||
landmark_3_y: landmarks[3][1],
|
||||
|
||||
landmark_4_x: landmarks[4][0],
|
||||
landmark_4_y: landmarks[4][1],
|
||||
|
||||
embedding: embeddingToBlob(face.embedding),
|
||||
|
||||
cluster_id: null,
|
||||
person_id: null,
|
||||
status: 'detected',
|
||||
|
||||
created_at: db.fn.now(),
|
||||
updated_at: db.fn.now(),
|
||||
};
|
||||
}
|
||||
|
||||
async function replacePhotoFaces(photoId, faces) {
|
||||
const rows = faces.map((face) => (
|
||||
faceToDatabaseRow(photoId, face)
|
||||
));
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx('faces')
|
||||
.where({ photo_id: photoId })
|
||||
.del();
|
||||
|
||||
if (rows.length > 0) {
|
||||
await trx('faces').insert(rows);
|
||||
}
|
||||
});
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async function scanPhotoFacesById(photoId) {
|
||||
const cleanPhotoId = String(photoId || '').trim();
|
||||
|
||||
if (!cleanPhotoId) {
|
||||
throw new Error('photoId mancante');
|
||||
}
|
||||
|
||||
const photo = await db('photos')
|
||||
.where({ id: cleanPhotoId })
|
||||
.whereNull('deleted_at')
|
||||
.first();
|
||||
|
||||
if (!photo) {
|
||||
throw new Error(
|
||||
`Foto non trovata o eliminata: ${cleanPhotoId}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
photo.mime_type
|
||||
&& !String(photo.mime_type).startsWith('image/')
|
||||
) {
|
||||
throw new Error(
|
||||
`Il record non è un'immagine: `
|
||||
+ `${photo.mime_type}`,
|
||||
);
|
||||
}
|
||||
|
||||
const imagePath =
|
||||
await resolveFilePathForEntryId(cleanPhotoId);
|
||||
|
||||
if (!imagePath) {
|
||||
throw new Error(
|
||||
`Percorso fisico non risolto per photo_id=${cleanPhotoId}`,
|
||||
);
|
||||
}
|
||||
|
||||
let fileStat;
|
||||
|
||||
try {
|
||||
fileStat = await fs.promises.stat(imagePath);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`File non accessibile: ${imagePath}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!fileStat.isFile()) {
|
||||
throw new Error(
|
||||
`Il percorso non è un file: ${imagePath}`,
|
||||
);
|
||||
}
|
||||
|
||||
const rotationDegrees =
|
||||
photo.rotation == null
|
||||
? 0
|
||||
: Number(photo.rotation);
|
||||
|
||||
const result = await runFaceScan(
|
||||
imagePath,
|
||||
rotationDegrees,
|
||||
);
|
||||
|
||||
const insertedFaces = await replacePhotoFaces(
|
||||
cleanPhotoId,
|
||||
result.faces,
|
||||
);
|
||||
|
||||
return {
|
||||
photo_id: cleanPhotoId,
|
||||
image_path: imagePath,
|
||||
image_width: result.image.width,
|
||||
image_height: result.image.height,
|
||||
photo_rotation: photo.rotation ?? null,
|
||||
applied_rotation: result.image.rotation,
|
||||
faces_detected: result.faces.length,
|
||||
faces_inserted: insertedFaces,
|
||||
execution_ms: result.execution_ms,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
EMBEDDING_DIM,
|
||||
EMBEDDING_BYTES,
|
||||
embeddingToBlob,
|
||||
validateFaceScanResult,
|
||||
runFaceScan,
|
||||
replacePhotoFaces,
|
||||
scanPhotoFacesById,
|
||||
};
|
||||
108
api_v1/scanner/geocodeQueue.js
Normal file
108
api_v1/scanner/geocodeQueue.js
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
const crypto = require('crypto');
|
||||
|
||||
const db = require('../../db/knex');
|
||||
|
||||
function normalizeCoordinate(value) {
|
||||
if (value == null || value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const coordinate = Number(value);
|
||||
|
||||
if (!Number.isFinite(coordinate)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return coordinate;
|
||||
}
|
||||
|
||||
async function enqueueGeocodeJob({
|
||||
entityType,
|
||||
entityId,
|
||||
lat,
|
||||
lon,
|
||||
}) {
|
||||
if (
|
||||
entityType !== 'photo'
|
||||
&& entityType !== 'activity'
|
||||
) {
|
||||
throw new Error(
|
||||
`Tipo entità geocode non valido: ${entityType}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!entityId) {
|
||||
throw new Error(
|
||||
'entityId geocode mancante'
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedLat = normalizeCoordinate(lat);
|
||||
const normalizedLon = normalizeCoordinate(lon);
|
||||
|
||||
if (
|
||||
normalizedLat == null
|
||||
|| normalizedLon == null
|
||||
) {
|
||||
return {
|
||||
queued: false,
|
||||
reason: 'coordinates_missing',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedLat < -90
|
||||
|| normalizedLat > 90
|
||||
|| normalizedLon < -180
|
||||
|| normalizedLon > 180
|
||||
) {
|
||||
return {
|
||||
queued: false,
|
||||
reason: 'coordinates_invalid',
|
||||
};
|
||||
}
|
||||
|
||||
const now = db.fn.now();
|
||||
|
||||
await db('geocode_jobs')
|
||||
.insert({
|
||||
id: crypto.randomUUID(),
|
||||
entity_type: entityType,
|
||||
entity_id: String(entityId),
|
||||
lat: normalizedLat,
|
||||
lon: normalizedLon,
|
||||
status: 'pending',
|
||||
attempts: 0,
|
||||
last_error: null,
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
.onConflict([
|
||||
'entity_type',
|
||||
'entity_id',
|
||||
])
|
||||
.merge({
|
||||
lat: normalizedLat,
|
||||
lon: normalizedLon,
|
||||
status: 'pending',
|
||||
attempts: 0,
|
||||
last_error: null,
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
updated_at: now,
|
||||
});
|
||||
|
||||
return {
|
||||
queued: true,
|
||||
entity_type: entityType,
|
||||
entity_id: String(entityId),
|
||||
lat: normalizedLat,
|
||||
lon: normalizedLon,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
enqueueGeocodeJob,
|
||||
};
|
||||
551
api_v1/scanner/geocodeWorker.js
Normal file
551
api_v1/scanner/geocodeWorker.js
Normal file
|
|
@ -0,0 +1,551 @@
|
|||
const db = require('../../db/knex');
|
||||
|
||||
const loc = require('../geo');
|
||||
const getElevation = require('./elevation');
|
||||
|
||||
function serializeError(error) {
|
||||
const value =
|
||||
error?.stack
|
||||
|| error?.message
|
||||
|| String(error);
|
||||
|
||||
return String(value).slice(0, 16000);
|
||||
}
|
||||
|
||||
function buildCacheKey(lat, lon) {
|
||||
return (
|
||||
`${Number(lat).toFixed(4)}:`
|
||||
+ `${Number(lon).toFixed(4)}`
|
||||
);
|
||||
}
|
||||
|
||||
function parseLocation(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return value;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function serializeLocation(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
async function claimNextPendingJob() {
|
||||
return db.transaction(async (trx) => {
|
||||
const job = await trx('geocode_jobs')
|
||||
.where({ status: 'pending' })
|
||||
.orderBy('created_at', 'asc')
|
||||
.orderBy('id', 'asc')
|
||||
.first();
|
||||
|
||||
if (!job) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const updated = await trx('geocode_jobs')
|
||||
.where({
|
||||
id: job.id,
|
||||
status: 'pending',
|
||||
})
|
||||
.update({
|
||||
status: 'processing',
|
||||
attempts: trx.raw('attempts + 1'),
|
||||
last_error: null,
|
||||
started_at: trx.fn.now(),
|
||||
completed_at: null,
|
||||
updated_at: trx.fn.now(),
|
||||
});
|
||||
|
||||
if (updated !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trx('geocode_jobs')
|
||||
.where({ id: job.id })
|
||||
.first();
|
||||
});
|
||||
}
|
||||
|
||||
async function completeJob(jobId) {
|
||||
const updated = await db('geocode_jobs')
|
||||
.where({
|
||||
id: jobId,
|
||||
status: 'processing',
|
||||
})
|
||||
.update({
|
||||
status: 'done',
|
||||
last_error: null,
|
||||
completed_at: db.fn.now(),
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
|
||||
if (updated !== 1) {
|
||||
throw new Error(
|
||||
`Impossibile completare il job id=${jobId}`,
|
||||
);
|
||||
}
|
||||
|
||||
return 'done';
|
||||
}
|
||||
|
||||
async function failJob(jobId, error) {
|
||||
const updated = await db('geocode_jobs')
|
||||
.where({
|
||||
id: jobId,
|
||||
status: 'processing',
|
||||
})
|
||||
.update({
|
||||
status: 'error',
|
||||
last_error: serializeError(error),
|
||||
completed_at: db.fn.now(),
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
|
||||
if (updated !== 1) {
|
||||
console.error(
|
||||
'[geocodeWorker] impossibile registrare errore:',
|
||||
jobId,
|
||||
);
|
||||
|
||||
return 'missing';
|
||||
}
|
||||
|
||||
return 'error';
|
||||
}
|
||||
|
||||
async function loadEntity(job) {
|
||||
if (job.entity_type === 'photo') {
|
||||
return db('photos')
|
||||
.select([
|
||||
'id',
|
||||
'lat',
|
||||
'lon',
|
||||
'alt',
|
||||
'location',
|
||||
])
|
||||
.where({ id: job.entity_id })
|
||||
.whereNull('deleted_at')
|
||||
.first();
|
||||
}
|
||||
|
||||
if (job.entity_type === 'activity') {
|
||||
return db('activities')
|
||||
.select([
|
||||
'id',
|
||||
'center_latitude',
|
||||
'center_longitude',
|
||||
'location',
|
||||
])
|
||||
.where({ id: job.entity_id })
|
||||
.whereNull('deleted_at')
|
||||
.first();
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Tipo entità non valido: ${job.entity_type}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function loadCache(cacheKey) {
|
||||
return db('geocode_cache')
|
||||
.where({ cache_key: cacheKey })
|
||||
.first();
|
||||
}
|
||||
|
||||
async function saveCache({
|
||||
cacheKey,
|
||||
lat,
|
||||
lon,
|
||||
alt,
|
||||
location,
|
||||
}) {
|
||||
const existing = await loadCache(cacheKey);
|
||||
|
||||
const finalAlt =
|
||||
alt != null
|
||||
? Number(alt)
|
||||
: existing?.alt ?? null;
|
||||
|
||||
const finalLocation =
|
||||
serializeLocation(location)
|
||||
|| existing?.location
|
||||
|| null;
|
||||
|
||||
const now = db.fn.now();
|
||||
|
||||
await db('geocode_cache')
|
||||
.insert({
|
||||
cache_key: cacheKey,
|
||||
lat,
|
||||
lon,
|
||||
alt: finalAlt,
|
||||
location: finalLocation,
|
||||
hits: existing?.hits ?? 0,
|
||||
created_at:
|
||||
existing?.created_at || now,
|
||||
updated_at: now,
|
||||
})
|
||||
.onConflict('cache_key')
|
||||
.merge({
|
||||
lat,
|
||||
lon,
|
||||
alt: finalAlt,
|
||||
location: finalLocation,
|
||||
updated_at: now,
|
||||
});
|
||||
}
|
||||
|
||||
async function recordCacheHit(cacheKey) {
|
||||
await db('geocode_cache')
|
||||
.where({ cache_key: cacheKey })
|
||||
.update({
|
||||
hits: db.raw('hits + 1'),
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
}
|
||||
|
||||
async function updateEntity({
|
||||
job,
|
||||
alt,
|
||||
location,
|
||||
}) {
|
||||
const locationJson =
|
||||
serializeLocation(location);
|
||||
|
||||
if (job.entity_type === 'photo') {
|
||||
const updated = await db('photos')
|
||||
.where({ id: job.entity_id })
|
||||
.whereNull('deleted_at')
|
||||
.update({
|
||||
alt: alt ?? null,
|
||||
location: locationJson,
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
|
||||
if (updated !== 1) {
|
||||
throw new Error(
|
||||
`Foto non trovata: ${job.entity_id}`,
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (job.entity_type === 'activity') {
|
||||
const updated = await db('activities')
|
||||
.where({ id: job.entity_id })
|
||||
.whereNull('deleted_at')
|
||||
.update({
|
||||
location: locationJson,
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
|
||||
if (updated !== 1) {
|
||||
throw new Error(
|
||||
`Attività non trovata: ${job.entity_id}`,
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Tipo entità non valido: ${job.entity_type}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function processClaimedJob(job) {
|
||||
if (!job?.id || !job?.entity_id) {
|
||||
throw new Error('Job geocode non valido');
|
||||
}
|
||||
|
||||
const workerStartedAt = Date.now();
|
||||
|
||||
console.log(
|
||||
'[geocodeWorker] processing',
|
||||
{
|
||||
job_id: job.id,
|
||||
entity_type: job.entity_type,
|
||||
entity_id: job.entity_id,
|
||||
attempt: job.attempts,
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const lat = Number(job.lat);
|
||||
const lon = Number(job.lon);
|
||||
|
||||
if (
|
||||
!Number.isFinite(lat)
|
||||
|| !Number.isFinite(lon)
|
||||
|| lat < -90
|
||||
|| lat > 90
|
||||
|| lon < -180
|
||||
|| lon > 180
|
||||
) {
|
||||
throw new Error(
|
||||
`Coordinate non valide: lat=${job.lat}, lon=${job.lon}`,
|
||||
);
|
||||
}
|
||||
|
||||
const entity = await loadEntity(job);
|
||||
|
||||
if (!entity) {
|
||||
throw new Error(
|
||||
`Entità non trovata: `
|
||||
+ `${job.entity_type}/${job.entity_id}`,
|
||||
);
|
||||
}
|
||||
|
||||
const cacheKey = buildCacheKey(lat, lon);
|
||||
const cached = await loadCache(cacheKey);
|
||||
|
||||
const altitudeFromCache =
|
||||
job.entity_type === 'photo'
|
||||
&& entity.alt == null
|
||||
&& cached?.alt != null;
|
||||
|
||||
const locationFromCache =
|
||||
!parseLocation(entity.location)
|
||||
&& Boolean(
|
||||
parseLocation(cached?.location)
|
||||
);
|
||||
|
||||
const cacheUsed =
|
||||
altitudeFromCache
|
||||
|| locationFromCache;
|
||||
|
||||
let altitude =
|
||||
job.entity_type === 'photo'
|
||||
? entity.alt ?? cached?.alt ?? null
|
||||
: null;
|
||||
|
||||
let location =
|
||||
parseLocation(entity.location)
|
||||
|| parseLocation(cached?.location)
|
||||
|| null;
|
||||
|
||||
if (cacheUsed) {
|
||||
await recordCacheHit(cacheKey);
|
||||
}
|
||||
|
||||
if (
|
||||
job.entity_type === 'photo'
|
||||
&& altitude == null
|
||||
) {
|
||||
altitude = await getElevation(
|
||||
lat,
|
||||
lon,
|
||||
);
|
||||
}
|
||||
|
||||
if (!location) {
|
||||
location = await loc(
|
||||
lon,
|
||||
lat,
|
||||
);
|
||||
}
|
||||
|
||||
if (!location) {
|
||||
throw new Error(
|
||||
`Reverse geocoding senza risultato: `
|
||||
+ `lat=${lat}, lon=${lon}`,
|
||||
);
|
||||
}
|
||||
|
||||
await saveCache({
|
||||
cacheKey,
|
||||
lat,
|
||||
lon,
|
||||
alt: altitude,
|
||||
location,
|
||||
});
|
||||
|
||||
await updateEntity({
|
||||
job,
|
||||
alt: altitude,
|
||||
location,
|
||||
});
|
||||
|
||||
const finalStatus =
|
||||
await completeJob(job.id);
|
||||
|
||||
const summary = {
|
||||
status: finalStatus,
|
||||
job_id: job.id,
|
||||
entity_type: job.entity_type,
|
||||
entity_id: job.entity_id,
|
||||
attempt: job.attempts,
|
||||
cache_key: cacheKey,
|
||||
cache_used: cacheUsed,
|
||||
altitude_from_cache: altitudeFromCache,
|
||||
location_from_cache: locationFromCache,
|
||||
altitude:
|
||||
job.entity_type === 'photo'
|
||||
? altitude
|
||||
: null,
|
||||
worker_ms:
|
||||
Date.now() - workerStartedAt,
|
||||
};
|
||||
|
||||
console.log(
|
||||
'[geocodeWorker] done',
|
||||
summary,
|
||||
);
|
||||
|
||||
return summary;
|
||||
} catch (error) {
|
||||
const finalStatus = await failJob(
|
||||
job.id,
|
||||
error,
|
||||
);
|
||||
|
||||
const summary = {
|
||||
status: finalStatus,
|
||||
job_id: job.id,
|
||||
entity_type: job.entity_type,
|
||||
entity_id: job.entity_id,
|
||||
attempt: job.attempts,
|
||||
error: serializeError(error),
|
||||
worker_ms:
|
||||
Date.now() - workerStartedAt,
|
||||
};
|
||||
|
||||
console.error(
|
||||
'[geocodeWorker] error',
|
||||
summary,
|
||||
);
|
||||
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
|
||||
async function processNextJob() {
|
||||
const job = await claimNextPendingJob();
|
||||
|
||||
if (!job) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return processClaimedJob(job);
|
||||
}
|
||||
|
||||
async function runWorker(options = {}) {
|
||||
const maxJobs =
|
||||
options.maxJobs == null
|
||||
? null
|
||||
: Number(options.maxJobs);
|
||||
|
||||
if (
|
||||
maxJobs !== null
|
||||
&& (
|
||||
!Number.isInteger(maxJobs)
|
||||
|| maxJobs <= 0
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'maxJobs deve essere un intero positivo',
|
||||
);
|
||||
}
|
||||
|
||||
const shouldStop =
|
||||
typeof options.shouldStop === 'function'
|
||||
? options.shouldStop
|
||||
: () => false;
|
||||
|
||||
const results = [];
|
||||
|
||||
while (
|
||||
!shouldStop()
|
||||
&& (
|
||||
maxJobs === null
|
||||
|| results.length < maxJobs
|
||||
)
|
||||
) {
|
||||
const result = await processNextJob();
|
||||
|
||||
if (!result) {
|
||||
break;
|
||||
}
|
||||
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
return {
|
||||
jobs_processed: results.length,
|
||||
jobs_succeeded: results.filter(
|
||||
(result) => result.status === 'done',
|
||||
).length,
|
||||
jobs_failed: results.filter(
|
||||
(result) => result.status === 'error',
|
||||
).length,
|
||||
stopped: shouldStop(),
|
||||
results,
|
||||
};
|
||||
}
|
||||
|
||||
async function resetStaleProcessingJobs(
|
||||
staleMinutes = 30,
|
||||
) {
|
||||
const minutes = Number(staleMinutes);
|
||||
|
||||
if (
|
||||
!Number.isFinite(minutes)
|
||||
|| minutes <= 0
|
||||
) {
|
||||
throw new Error(
|
||||
'staleMinutes deve essere positivo',
|
||||
);
|
||||
}
|
||||
|
||||
const modifier =
|
||||
`-${Math.floor(minutes)} minutes`;
|
||||
|
||||
return db('geocode_jobs')
|
||||
.where({ status: 'processing' })
|
||||
.where(
|
||||
'started_at',
|
||||
'<',
|
||||
db.raw(
|
||||
"datetime('now', ?)",
|
||||
[modifier],
|
||||
),
|
||||
)
|
||||
.update({
|
||||
status: 'pending',
|
||||
last_error:
|
||||
'Job recuperato dopo arresto del worker',
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
serializeError,
|
||||
buildCacheKey,
|
||||
claimNextPendingJob,
|
||||
completeJob,
|
||||
failJob,
|
||||
processClaimedJob,
|
||||
processNextJob,
|
||||
runWorker,
|
||||
resetStaleProcessingJobs,
|
||||
};
|
||||
69
api_v1/scanner/gps.js
Normal file
69
api_v1/scanner/gps.js
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
const { exec } = require('child_process');
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// FOTO: GPS da ExifReader
|
||||
// -----------------------------------------------------------------------------
|
||||
function extractGpsFromExif(tags) {
|
||||
if (!tags?.gps) return null;
|
||||
|
||||
const lat = tags.gps.Latitude;
|
||||
const lng = tags.gps.Longitude;
|
||||
const alt = tags.gps.Altitude;
|
||||
|
||||
if (lat == null || lng == null) return null;
|
||||
|
||||
return {
|
||||
lat: Number(lat),
|
||||
lng: Number(lng),
|
||||
alt: alt != null ? Number(alt) : null
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// VIDEO: GPS via exiftool (VERSIONE ORIGINALE CHE FUNZIONA)
|
||||
// -----------------------------------------------------------------------------
|
||||
function extractGpsWithExiftool(videoPath) {
|
||||
//console.log(videoPath);
|
||||
return new Promise((resolve) => {
|
||||
const cmd = `exiftool -n -G1 -a -gps:all -quicktime:all -user:all "${videoPath}"`;
|
||||
|
||||
exec(cmd, (err, stdout) => {
|
||||
if (err || !stdout) return resolve(null);
|
||||
|
||||
// 1) GPS Coordinates : <lat> <lng>
|
||||
const userData = stdout.match(/GPS Coordinates\s*:\s*([0-9.\-]+)\s+([0-9.\-]+)/i);
|
||||
if (userData) {
|
||||
return resolve({
|
||||
lat: Number(userData[1]),
|
||||
lng: Number(userData[2]),
|
||||
alt: null
|
||||
});
|
||||
}
|
||||
|
||||
// 2) GPSLatitude / GPSLongitude
|
||||
const lat1 = stdout.match(/GPSLatitude\s*:\s*([0-9.\-]+)/i);
|
||||
const lng1 = stdout.match(/GPSLongitude\s*:\s*([0-9.\-]+)/i);
|
||||
if (lat1 && lng1) {
|
||||
return resolve({
|
||||
lat: Number(lat1[1]),
|
||||
lng: Number(lng1[1]),
|
||||
alt: null
|
||||
});
|
||||
}
|
||||
|
||||
// 3) GPSCoordinates : <lat> <lng>
|
||||
const coords = stdout.match(/GPSCoordinates\s*:\s*([0-9.\-]+)\s+([0-9.\-]+)/i);
|
||||
if (coords) {
|
||||
return resolve({
|
||||
lat: Number(coords[1]),
|
||||
lng: Number(coords[2]),
|
||||
alt: null
|
||||
});
|
||||
}
|
||||
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { extractGpsFromExif, extractGpsWithExiftool };
|
||||
43
api_v1/scanner/logger.js
Normal file
43
api_v1/scanner/logger.js
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
// scanner/logger.js
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const LOG_MODE = process.env.LOG_MODE || "console"; // console | file | both
|
||||
const LOG_DIR = process.env.LOG_DIR || null;
|
||||
const LOG_FILE = process.env.LOG_FILE || "scan.log";
|
||||
|
||||
let stream = null;
|
||||
|
||||
function resolveLogPath() {
|
||||
if (LOG_DIR) {
|
||||
return path.resolve(__dirname, "..", "..", LOG_DIR, LOG_FILE);
|
||||
}
|
||||
return path.resolve(__dirname, "..", "..", LOG_FILE);
|
||||
}
|
||||
|
||||
if (LOG_MODE === "file" || LOG_MODE === "both") {
|
||||
const logPath = resolveLogPath();
|
||||
|
||||
// crea la directory se non esiste
|
||||
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
||||
|
||||
stream = fs.createWriteStream(logPath, { flags: "a" });
|
||||
}
|
||||
|
||||
function ts() {
|
||||
return new Date().toISOString().replace("T", " ").split(".")[0];
|
||||
}
|
||||
|
||||
function log(message) {
|
||||
const line = `${ts()} ${message}\n`;
|
||||
|
||||
if (LOG_MODE === "console" || LOG_MODE === "both") {
|
||||
process.stdout.write(line);
|
||||
}
|
||||
|
||||
if (LOG_MODE === "file" || LOG_MODE === "both") {
|
||||
stream.write(line);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { log };
|
||||
109
api_v1/scanner/orphanCleanup.js
Normal file
109
api_v1/scanner/orphanCleanup.js
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
// api_v1/scanner/orphanCleanup.js
|
||||
const fsp = require("fs/promises");
|
||||
const path = require("path");
|
||||
|
||||
module.exports = function createCleanupFunctions(db, RETENTION_DAYS = 30) {
|
||||
// 1) Recupera gli ID dal DB per una cartella specifica
|
||||
async function buildIdsListForFolder(userName, cartella) {
|
||||
try {
|
||||
const rows = await db("photos")
|
||||
.select("id")
|
||||
.where({ user: userName, cartella });
|
||||
|
||||
return rows.map((r) => r.id);
|
||||
} catch (err) {
|
||||
console.error("Errore buildIdsListForFolder:", err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Rimuove un ID dalla lista (invariati)
|
||||
function removeIdFromList(idsIndex, id) {
|
||||
return idsIndex.filter((x) => x !== id);
|
||||
}
|
||||
|
||||
// 3) Cancella thumbs dal filesystem
|
||||
async function deleteThumbsById(id) {
|
||||
const rec = await db("photos").where({ id }).first();
|
||||
if (!rec) return false;
|
||||
|
||||
const thumbs = [rec.thub1, rec.thub2, rec.cast].filter(Boolean);
|
||||
let deleted = false;
|
||||
|
||||
for (const t of thumbs) {
|
||||
const abs = path.resolve(__dirname, "..", "..", "public", t);
|
||||
|
||||
// 🔍 Log del path reale
|
||||
console.log(` 📁 Tentativo cancellazione thumb: ${abs}`);
|
||||
|
||||
try {
|
||||
await fsp.rm(abs, { force: true });
|
||||
console.log(` 🔴 Thumb eliminato: ${abs}`);
|
||||
deleted = true;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
// 4) Soft delete + Hard delete con retention
|
||||
async function deleteFromDB(id, userName) {
|
||||
const now = new Date();
|
||||
const nowIso = now.toISOString();
|
||||
|
||||
// Recupera record attuale
|
||||
const rec = await db("photos").where({ id, user: userName }).first();
|
||||
|
||||
if (!rec) {
|
||||
console.log(`⚠️ deleteFromDB: foto ${id} non trovata`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Se NON era già soft-deleted → SOFT DELETE
|
||||
if (!rec.deleted_at) {
|
||||
await db("photos").where({ id, user: userName }).update({
|
||||
deleted_at: nowIso,
|
||||
updated_at: nowIso,
|
||||
});
|
||||
|
||||
console.log(`🟡 Soft delete → id=${id}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Se ERA già soft-deleted → controlla retention
|
||||
const deletedAt = new Date(rec.deleted_at);
|
||||
const retentionMs = RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||
|
||||
const shouldHardDelete = now - deletedAt > retentionMs;
|
||||
|
||||
if (!shouldHardDelete) {
|
||||
console.log(`🟡 Soft delete già presente → id=${id} (in retention)`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 🔥 HARD DELETE
|
||||
console.log(`🔴 HARD DELETE → id=${id}`);
|
||||
|
||||
// 1) Elimina thumbs
|
||||
await deleteThumbsById(id);
|
||||
|
||||
// 2) Elimina record dal DB
|
||||
await db("photos").where({ id, user: userName }).del();
|
||||
|
||||
// 3) Registra hard delete per progressive sync
|
||||
await db("deleted_hard").insert({
|
||||
id,
|
||||
user: userName,
|
||||
deleted_at: nowIso,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
buildIdsListForFolder,
|
||||
removeIdFromList,
|
||||
deleteThumbsById,
|
||||
deleteFromDB,
|
||||
};
|
||||
};
|
||||
100
api_v1/scanner/parseActivityFile.js
Normal file
100
api_v1/scanner/parseActivityFile.js
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
// api_v1/scanner/parseActivityFile.js
|
||||
const path = require('path');
|
||||
|
||||
// Parser specifici
|
||||
const parseFit = require('./parsers/parseFit');
|
||||
const parseGpx = require('./parsers/parseGpx');
|
||||
const parseTcx = require('./parsers/parseTcx');
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// parseActivityFile → rileva tipo e chiama parser specifico
|
||||
// ---------------------------------------------------------
|
||||
async function parseActivityFile(absPath) {
|
||||
const ext = path.extname(absPath).toLowerCase();
|
||||
|
||||
let parsed;
|
||||
|
||||
if (ext === '.fit') {
|
||||
parsed = await parseFit(absPath);
|
||||
} else if (ext === '.gpx') {
|
||||
parsed = await parseGpx(absPath);
|
||||
} else if (ext === '.tcx') {
|
||||
parsed = await parseTcx(absPath);
|
||||
} else {
|
||||
throw new Error(`Formato attività non supportato: ${ext}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Normalizzazione campi base
|
||||
// ---------------------------------------------------------
|
||||
const fileName = path.basename(absPath);
|
||||
const fileExtension = ext.replace('.', '');
|
||||
|
||||
return {
|
||||
file_name: fileName,
|
||||
file_extension: fileExtension,
|
||||
format: parsed.format || fileExtension.toUpperCase(),
|
||||
|
||||
display_name: parsed.display_name || fileName,
|
||||
route_name: parsed.route_name || null,
|
||||
title: parsed.title || null,
|
||||
description: parsed.description || null,
|
||||
notes: parsed.notes || null,
|
||||
|
||||
sport: parsed.sport || null,
|
||||
activity_type: parsed.activity_type || null,
|
||||
device_manufacturer: parsed.device_manufacturer || null,
|
||||
device_model: parsed.device_model || null,
|
||||
source_app: parsed.source_app || null,
|
||||
|
||||
start_time_millis: parsed.start_time_millis ?? null,
|
||||
end_time_millis: parsed.end_time_millis ?? null,
|
||||
duration_millis: parsed.duration_millis ?? null,
|
||||
|
||||
distance_meters: parsed.distance_meters ?? null,
|
||||
elevation_gain_meters: parsed.elevation_gain_meters ?? null,
|
||||
elevation_loss_meters: parsed.elevation_loss_meters ?? null,
|
||||
|
||||
min_elevation_meters: parsed.min_elevation_meters ?? null,
|
||||
max_elevation_meters: parsed.max_elevation_meters ?? null,
|
||||
|
||||
file_distance_meters: parsed.file_distance_meters ?? null,
|
||||
file_elevation_gain_meters: parsed.file_elevation_gain_meters ?? null,
|
||||
|
||||
computed_distance_meters: parsed.computed_distance_meters ?? null,
|
||||
computed_elevation_gain_meters: parsed.computed_elevation_gain_meters ?? null,
|
||||
|
||||
average_speed_mps: parsed.average_speed_mps ?? null,
|
||||
max_speed_mps: parsed.max_speed_mps ?? null,
|
||||
average_pace_sec_per_km: parsed.average_pace_sec_per_km ?? null,
|
||||
|
||||
average_heart_rate: parsed.average_heart_rate ?? null,
|
||||
max_heart_rate: parsed.max_heart_rate ?? null,
|
||||
average_cadence: parsed.average_cadence ?? null,
|
||||
max_cadence: parsed.max_cadence ?? null,
|
||||
|
||||
calories: parsed.calories ?? null,
|
||||
power_avg: parsed.power_avg ?? null,
|
||||
power_max: parsed.power_max ?? null,
|
||||
|
||||
point_count: parsed.point_count ?? 0,
|
||||
segment_count: parsed.segment_count ?? 0,
|
||||
lap_count: parsed.lap_count ?? 0,
|
||||
|
||||
min_latitude: parsed.min_latitude ?? null,
|
||||
max_latitude: parsed.max_latitude ?? null,
|
||||
min_longitude: parsed.min_longitude ?? null,
|
||||
max_longitude: parsed.max_longitude ?? null,
|
||||
center_latitude: parsed.center_latitude ?? null,
|
||||
center_longitude: parsed.center_longitude ?? null,
|
||||
|
||||
tags: parsed.tags || [],
|
||||
|
||||
// IMPORTANTISSIMO: preview percorso per thumb gallery
|
||||
track_preview: parsed.track_preview || [],
|
||||
|
||||
diagnostics: parsed.diagnostics || null,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = parseActivityFile;
|
||||
408
api_v1/scanner/parsers/parseFit.js
Normal file
408
api_v1/scanner/parsers/parseFit.js
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
// api_v1/scanner/parsers/parseFit.js
|
||||
const { execFile } = require("child_process");
|
||||
|
||||
function readFitFrames(absPath) {
|
||||
return new Promise(
|
||||
(resolve, reject) => {
|
||||
execFile(
|
||||
"/home/orangepi/.local/pipx/venvs/fitdecode/bin/python",
|
||||
[
|
||||
"api_v1/scanner/parsers/parse_fit.py",
|
||||
absPath,
|
||||
],
|
||||
{
|
||||
maxBuffer:
|
||||
1024 * 1024 * 50,
|
||||
},
|
||||
(error, stdout) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
resolve(
|
||||
JSON.parse(stdout),
|
||||
);
|
||||
} catch (parseError) {
|
||||
reject(
|
||||
new Error(
|
||||
"Errore nel parsing JSON FIT: " +
|
||||
parseError.message,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// FIT position_lat / position_long sono in semicircles.
|
||||
// Conversione ufficiale: degrees = semicircles * 180 / 2^31
|
||||
function fitSemicirclesToDegrees(value) {
|
||||
if (value == null) return null;
|
||||
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return null;
|
||||
|
||||
return n * (180 / 2147483648);
|
||||
}
|
||||
|
||||
function buildTrackPointsFromFitRecords(
|
||||
records,
|
||||
) {
|
||||
if (!Array.isArray(records)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const points = [];
|
||||
|
||||
for (const record of records) {
|
||||
const fields =
|
||||
record?.fields || {};
|
||||
|
||||
const rawLat =
|
||||
fields.position_lat ??
|
||||
fields.latitude ??
|
||||
fields.lat;
|
||||
|
||||
const rawLng =
|
||||
fields.position_long ??
|
||||
fields.position_lon ??
|
||||
fields.longitude ??
|
||||
fields.lng ??
|
||||
fields.lon;
|
||||
|
||||
const lat =
|
||||
fitSemicirclesToDegrees(
|
||||
rawLat,
|
||||
);
|
||||
|
||||
const lng =
|
||||
fitSemicirclesToDegrees(
|
||||
rawLng,
|
||||
);
|
||||
|
||||
if (
|
||||
!Number.isFinite(lat) ||
|
||||
!Number.isFinite(lng)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const point = {
|
||||
lat,
|
||||
lng,
|
||||
};
|
||||
|
||||
const altitudeRaw =
|
||||
fields.altitude ??
|
||||
fields.enhanced_altitude;
|
||||
|
||||
const altitude =
|
||||
altitudeRaw != null
|
||||
? Number(altitudeRaw)
|
||||
: null;
|
||||
|
||||
if (
|
||||
Number.isFinite(altitude)
|
||||
) {
|
||||
point.alt = altitude;
|
||||
}
|
||||
|
||||
points.push(point);
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
function buildTrackPreviewFromFitRecords(
|
||||
records,
|
||||
maxPoints = 300,
|
||||
) {
|
||||
const points =
|
||||
buildTrackPointsFromFitRecords(
|
||||
records,
|
||||
);
|
||||
|
||||
if (
|
||||
points.length <= maxPoints
|
||||
) {
|
||||
return points;
|
||||
}
|
||||
|
||||
const step =
|
||||
Math.ceil(
|
||||
points.length / maxPoints,
|
||||
);
|
||||
|
||||
const preview = [];
|
||||
|
||||
for (
|
||||
let index = 0;
|
||||
index < points.length;
|
||||
index += step
|
||||
) {
|
||||
preview.push(
|
||||
points[index],
|
||||
);
|
||||
}
|
||||
|
||||
const last =
|
||||
points.at(-1);
|
||||
|
||||
const previewLast =
|
||||
preview.at(-1);
|
||||
|
||||
if (
|
||||
last &&
|
||||
(
|
||||
!previewLast ||
|
||||
previewLast.lat !==
|
||||
last.lat ||
|
||||
previewLast.lng !==
|
||||
last.lng
|
||||
)
|
||||
) {
|
||||
preview.push(last);
|
||||
}
|
||||
|
||||
return preview;
|
||||
}
|
||||
|
||||
async function parseFit(absPath) {
|
||||
const frames =
|
||||
await readFitFrames(
|
||||
absPath,
|
||||
);
|
||||
|
||||
// -------------------------------
|
||||
// 1. Trova i frame principali
|
||||
// -------------------------------
|
||||
const sessions = frames.filter(f => f.name === "session");
|
||||
const laps = frames.filter(f => f.name === "lap");
|
||||
const records = frames.filter(f => f.name === "record");
|
||||
const fileId = frames.find(f => f.name === "file_id");
|
||||
|
||||
const session = sessions[0]?.fields || {};
|
||||
const file = fileId?.fields || {};
|
||||
|
||||
// -------------------------------
|
||||
// 2. Tempi
|
||||
// -------------------------------
|
||||
const startTime = session.start_time || session.start_time_local || null;
|
||||
|
||||
const endTime = session.total_timer_time && startTime
|
||||
? new Date(new Date(startTime).getTime() + session.total_timer_time * 1000)
|
||||
: null;
|
||||
|
||||
const startMillis = startTime ? new Date(startTime).getTime() : null;
|
||||
const endMillis = endTime ? endTime.getTime() : null;
|
||||
|
||||
const durationMillis = session.total_timer_time
|
||||
? session.total_timer_time * 1000
|
||||
: endMillis && startMillis
|
||||
? endMillis - startMillis
|
||||
: null;
|
||||
|
||||
// -------------------------------
|
||||
// 3. Distanza / Elevazione
|
||||
// -------------------------------
|
||||
const distance = session.total_distance || null;
|
||||
const elevGain = session.total_ascent || null;
|
||||
const elevLoss = session.total_descent || null;
|
||||
|
||||
// -------------------------------
|
||||
// 4. HR / Cadenza / Velocità
|
||||
// -------------------------------
|
||||
const avgHr = session.avg_heart_rate || null;
|
||||
const maxHr = session.max_heart_rate || null;
|
||||
|
||||
const avgCad = session.avg_cadence || null;
|
||||
const maxCad = session.max_cadence || null;
|
||||
|
||||
const avgSpeed = session.avg_speed || null;
|
||||
const maxSpeed = session.max_speed || null;
|
||||
|
||||
// -------------------------------
|
||||
// 5. Bounding box GPS
|
||||
// -------------------------------
|
||||
let minLat = null;
|
||||
let maxLat = null;
|
||||
let minLon = null;
|
||||
let maxLon = null;
|
||||
let minElev = null;
|
||||
let maxElev = null;
|
||||
|
||||
for (const r of records) {
|
||||
const f = r.fields || {};
|
||||
|
||||
const lat = fitSemicirclesToDegrees(f.position_lat);
|
||||
const lon = fitSemicirclesToDegrees(f.position_long);
|
||||
|
||||
const eleRaw =
|
||||
f.altitude ??
|
||||
f.enhanced_altitude;
|
||||
|
||||
const ele = eleRaw != null ? Number(eleRaw) : null;
|
||||
|
||||
if (Number.isFinite(lat) && Number.isFinite(lon)) {
|
||||
minLat = minLat == null ? lat : Math.min(minLat, lat);
|
||||
maxLat = maxLat == null ? lat : Math.max(maxLat, lat);
|
||||
minLon = minLon == null ? lon : Math.min(minLon, lon);
|
||||
maxLon = maxLon == null ? lon : Math.max(maxLon, lon);
|
||||
}
|
||||
|
||||
if (Number.isFinite(ele)) {
|
||||
minElev = minElev == null ? ele : Math.min(minElev, ele);
|
||||
maxElev = maxElev == null ? ele : Math.max(maxElev, ele);
|
||||
}
|
||||
}
|
||||
|
||||
const centerLat =
|
||||
minLat != null && maxLat != null ? (minLat + maxLat) / 2 : null;
|
||||
|
||||
const centerLon =
|
||||
minLon != null && maxLon != null ? (minLon + maxLon) / 2 : null;
|
||||
|
||||
// -------------------------------
|
||||
// 6. Track preview per gallery
|
||||
// -------------------------------
|
||||
const trackPreview = buildTrackPreviewFromFitRecords(records, 300);
|
||||
|
||||
// Log utile temporaneo. Puoi rimuoverlo dopo il test.
|
||||
console.log("[parseFit] track preview", {
|
||||
file: absPath,
|
||||
records: records.length,
|
||||
preview: trackPreview.length,
|
||||
first: trackPreview[0],
|
||||
minLat,
|
||||
maxLat,
|
||||
minLon,
|
||||
maxLon,
|
||||
});
|
||||
|
||||
// -------------------------------
|
||||
// 7. Costruzione oggetto attività finale
|
||||
// -------------------------------
|
||||
return {
|
||||
format: "FIT",
|
||||
|
||||
display_name: session.sport || null,
|
||||
route_name: session.sport || null,
|
||||
title: session.sport || null,
|
||||
description: null,
|
||||
notes: null,
|
||||
|
||||
sport: session.sport || null,
|
||||
activity_type: session.sub_sport || null,
|
||||
device_manufacturer: file.manufacturer || null,
|
||||
device_model: file.product || null,
|
||||
source_app: null,
|
||||
|
||||
start_time_millis: startMillis,
|
||||
end_time_millis: endMillis,
|
||||
duration_millis: durationMillis,
|
||||
|
||||
distance_meters: distance,
|
||||
elevation_gain_meters: elevGain,
|
||||
elevation_loss_meters: elevLoss,
|
||||
|
||||
min_elevation_meters: minElev,
|
||||
max_elevation_meters: maxElev,
|
||||
|
||||
file_distance_meters: distance,
|
||||
file_elevation_gain_meters: elevGain,
|
||||
|
||||
computed_distance_meters: distance,
|
||||
computed_elevation_gain_meters: elevGain,
|
||||
|
||||
average_speed_mps: avgSpeed,
|
||||
max_speed_mps: maxSpeed,
|
||||
average_pace_sec_per_km: avgSpeed ? 1000 / avgSpeed : null,
|
||||
|
||||
average_heart_rate: avgHr,
|
||||
max_heart_rate: maxHr,
|
||||
average_cadence: avgCad,
|
||||
max_cadence: maxCad,
|
||||
|
||||
calories: session.total_calories || null,
|
||||
power_avg: session.avg_power || null,
|
||||
power_max: session.max_power || null,
|
||||
|
||||
point_count: records.length,
|
||||
segment_count: laps.length,
|
||||
lap_count: laps.length,
|
||||
|
||||
min_latitude: minLat,
|
||||
max_latitude: maxLat,
|
||||
min_longitude: minLon,
|
||||
max_longitude: maxLon,
|
||||
center_latitude: centerLat,
|
||||
center_longitude: centerLon,
|
||||
|
||||
// Preview ridotta del percorso per i thumb della gallery
|
||||
track_preview: trackPreview,
|
||||
|
||||
tags: [],
|
||||
|
||||
diagnostics: {
|
||||
source: "FIT",
|
||||
sessions: sessions.length,
|
||||
laps: laps.length,
|
||||
records: records.length,
|
||||
track_preview_points: trackPreview.length,
|
||||
coordinates_converted_from_semicircles: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function parseFitTrackPoints(
|
||||
absPath,
|
||||
) {
|
||||
if (!absPath) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const frames =
|
||||
await readFitFrames(
|
||||
absPath,
|
||||
);
|
||||
|
||||
const records =
|
||||
frames.filter(
|
||||
(frame) =>
|
||||
frame?.name ===
|
||||
"record",
|
||||
);
|
||||
|
||||
const points =
|
||||
buildTrackPointsFromFitRecords(
|
||||
records,
|
||||
);
|
||||
|
||||
/*
|
||||
console.log(
|
||||
"[parseFitTrackPoints] traccia completa",
|
||||
{
|
||||
file:
|
||||
absPath,
|
||||
|
||||
records:
|
||||
records.length,
|
||||
|
||||
gpsPoints:
|
||||
points.length,
|
||||
},
|
||||
);
|
||||
*/
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
module.exports =
|
||||
parseFit;
|
||||
|
||||
module.exports.parseFitTrackPoints =
|
||||
parseFitTrackPoints;
|
||||
204
api_v1/scanner/parsers/parseGpx.js
Normal file
204
api_v1/scanner/parsers/parseGpx.js
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
// api_v1/scanner/parsers/parseGpx.js
|
||||
const fsp = require('fs/promises');
|
||||
const xml2js = require('xml2js'); // npm install xml2js
|
||||
|
||||
function toMillis(date) {
|
||||
if (!date) return null;
|
||||
const d = new Date(date);
|
||||
return isNaN(d.getTime()) ? null : d.getTime();
|
||||
}
|
||||
|
||||
function buildTrackPreviewFromGpxPoints(points, maxPoints = 300) {
|
||||
if (!Array.isArray(points)) return [];
|
||||
|
||||
const clean = [];
|
||||
|
||||
for (const p of points) {
|
||||
const lat = p.$?.lat != null ? Number(p.$.lat) : null;
|
||||
const lng = p.$?.lon != null ? Number(p.$.lon) : null;
|
||||
const alt = p.ele != null ? Number(p.ele) : null;
|
||||
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue;
|
||||
|
||||
const point = { lat, lng };
|
||||
|
||||
if (Number.isFinite(alt)) {
|
||||
point.alt = alt;
|
||||
}
|
||||
|
||||
clean.push(point);
|
||||
}
|
||||
|
||||
if (clean.length <= maxPoints) return clean;
|
||||
|
||||
const step = Math.ceil(clean.length / maxPoints);
|
||||
const preview = [];
|
||||
|
||||
for (let i = 0; i < clean.length; i += step) {
|
||||
preview.push(clean[i]);
|
||||
}
|
||||
|
||||
const last = clean[clean.length - 1];
|
||||
const currentLast = preview[preview.length - 1];
|
||||
|
||||
if (
|
||||
!currentLast ||
|
||||
currentLast.lat !== last.lat ||
|
||||
currentLast.lng !== last.lng
|
||||
) {
|
||||
preview.push(last);
|
||||
}
|
||||
|
||||
return preview;
|
||||
}
|
||||
|
||||
async function parseGpx(absPath) {
|
||||
const xml = await fsp.readFile(absPath, 'utf8');
|
||||
|
||||
const parser = new xml2js.Parser({ explicitArray: false });
|
||||
const gpx = await parser.parseStringPromise(xml);
|
||||
|
||||
const root = gpx.gpx || gpx;
|
||||
const trk = Array.isArray(root.trk) ? root.trk[0] : root.trk;
|
||||
const name = trk?.name || null;
|
||||
|
||||
const segments = trk?.trkseg
|
||||
? (Array.isArray(trk.trkseg) ? trk.trkseg : [trk.trkseg])
|
||||
: [];
|
||||
|
||||
const points = [];
|
||||
|
||||
for (const seg of segments) {
|
||||
const segPts = seg.trkpt
|
||||
? (Array.isArray(seg.trkpt) ? seg.trkpt : [seg.trkpt])
|
||||
: [];
|
||||
|
||||
points.push(...segPts);
|
||||
}
|
||||
|
||||
let minLat = null;
|
||||
let maxLat = null;
|
||||
let minLon = null;
|
||||
let maxLon = null;
|
||||
let minElev = null;
|
||||
let maxElev = null;
|
||||
|
||||
let startTime = null;
|
||||
let endTime = null;
|
||||
let distance = null;
|
||||
|
||||
for (const p of points) {
|
||||
const lat = p.$?.lat != null ? Number(p.$.lat) : null;
|
||||
const lon = p.$?.lon != null ? Number(p.$.lon) : null;
|
||||
const ele = p.ele != null ? Number(p.ele) : null;
|
||||
const time = p.time || null;
|
||||
|
||||
if (Number.isFinite(lat) && Number.isFinite(lon)) {
|
||||
minLat = minLat == null ? lat : Math.min(minLat, lat);
|
||||
maxLat = maxLat == null ? lat : Math.max(maxLat, lat);
|
||||
minLon = minLon == null ? lon : Math.min(minLon, lon);
|
||||
maxLon = maxLon == null ? lon : Math.max(maxLon, lon);
|
||||
}
|
||||
|
||||
if (Number.isFinite(ele)) {
|
||||
minElev = minElev == null ? ele : Math.min(minElev, ele);
|
||||
maxElev = maxElev == null ? ele : Math.max(maxElev, ele);
|
||||
}
|
||||
|
||||
if (time) {
|
||||
if (!startTime) startTime = time;
|
||||
endTime = time;
|
||||
}
|
||||
}
|
||||
|
||||
const start_time_millis = toMillis(startTime);
|
||||
const end_time_millis = toMillis(endTime);
|
||||
|
||||
const duration_millis =
|
||||
start_time_millis && end_time_millis
|
||||
? end_time_millis - start_time_millis
|
||||
: null;
|
||||
|
||||
const centerLat =
|
||||
minLat != null && maxLat != null
|
||||
? (minLat + maxLat) / 2
|
||||
: null;
|
||||
|
||||
const centerLon =
|
||||
minLon != null && maxLon != null
|
||||
? (minLon + maxLon) / 2
|
||||
: null;
|
||||
|
||||
const trackPreview = buildTrackPreviewFromGpxPoints(points, 300);
|
||||
|
||||
return {
|
||||
format: 'GPX',
|
||||
|
||||
display_name: name,
|
||||
route_name: name,
|
||||
title: name,
|
||||
description: root.metadata?.desc || null,
|
||||
notes: null,
|
||||
|
||||
sport: root.metadata?.type || null,
|
||||
activity_type: null,
|
||||
device_manufacturer: root.metadata?.author?.name || null,
|
||||
device_model: null,
|
||||
source_app: root.metadata?.author?.name || null,
|
||||
|
||||
start_time_millis,
|
||||
end_time_millis,
|
||||
duration_millis,
|
||||
|
||||
distance_meters: distance,
|
||||
elevation_gain_meters: null,
|
||||
elevation_loss_meters: null,
|
||||
|
||||
min_elevation_meters: minElev,
|
||||
max_elevation_meters: maxElev,
|
||||
|
||||
file_distance_meters: distance,
|
||||
file_elevation_gain_meters: null,
|
||||
|
||||
computed_distance_meters: distance,
|
||||
computed_elevation_gain_meters: null,
|
||||
|
||||
average_speed_mps: null,
|
||||
max_speed_mps: null,
|
||||
average_pace_sec_per_km: null,
|
||||
|
||||
average_heart_rate: null,
|
||||
max_heart_rate: null,
|
||||
average_cadence: null,
|
||||
max_cadence: null,
|
||||
|
||||
calories: null,
|
||||
power_avg: null,
|
||||
power_max: null,
|
||||
|
||||
point_count: points.length,
|
||||
segment_count: segments.length,
|
||||
lap_count: 0,
|
||||
|
||||
min_latitude: minLat,
|
||||
max_latitude: maxLat,
|
||||
min_longitude: minLon,
|
||||
max_longitude: maxLon,
|
||||
center_latitude: centerLat,
|
||||
center_longitude: centerLon,
|
||||
|
||||
// Preview ridotta del percorso per i thumb della gallery
|
||||
track_preview: trackPreview,
|
||||
|
||||
tags: [],
|
||||
|
||||
diagnostics: {
|
||||
source: 'GPX',
|
||||
segments: segments.length,
|
||||
points: points.length,
|
||||
track_preview_points: trackPreview.length
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = parseGpx;
|
||||
298
api_v1/scanner/parsers/parseTcx.js
Normal file
298
api_v1/scanner/parsers/parseTcx.js
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
// api_v1/scanner/parsers/parseTcx.js
|
||||
const fsp = require('fs/promises');
|
||||
const xml2js = require('xml2js');
|
||||
|
||||
function toMillis(date) {
|
||||
if (!date) return null;
|
||||
const d = new Date(date);
|
||||
return isNaN(d.getTime()) ? null : d.getTime();
|
||||
}
|
||||
|
||||
function buildTrackPreviewFromTcxPoints(points, maxPoints = 300) {
|
||||
if (!Array.isArray(points)) return [];
|
||||
|
||||
const clean = [];
|
||||
|
||||
for (const p of points) {
|
||||
const pos = p.Position || {};
|
||||
|
||||
const lat = pos.LatitudeDegrees != null
|
||||
? Number(pos.LatitudeDegrees)
|
||||
: null;
|
||||
|
||||
const lng = pos.LongitudeDegrees != null
|
||||
? Number(pos.LongitudeDegrees)
|
||||
: null;
|
||||
|
||||
const alt = p.AltitudeMeters != null
|
||||
? Number(p.AltitudeMeters)
|
||||
: null;
|
||||
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue;
|
||||
|
||||
const point = { lat, lng };
|
||||
|
||||
if (Number.isFinite(alt)) {
|
||||
point.alt = alt;
|
||||
}
|
||||
|
||||
clean.push(point);
|
||||
}
|
||||
|
||||
if (clean.length <= maxPoints) return clean;
|
||||
|
||||
const step = Math.ceil(clean.length / maxPoints);
|
||||
const preview = [];
|
||||
|
||||
for (let i = 0; i < clean.length; i += step) {
|
||||
preview.push(clean[i]);
|
||||
}
|
||||
|
||||
const last = clean[clean.length - 1];
|
||||
const currentLast = preview[preview.length - 1];
|
||||
|
||||
if (
|
||||
!currentLast ||
|
||||
currentLast.lat !== last.lat ||
|
||||
currentLast.lng !== last.lng
|
||||
) {
|
||||
preview.push(last);
|
||||
}
|
||||
|
||||
return preview;
|
||||
}
|
||||
|
||||
async function parseTcx(absPath) {
|
||||
const xml = await fsp.readFile(absPath, 'utf8');
|
||||
|
||||
const parser = new xml2js.Parser({ explicitArray: false });
|
||||
const tcx = await parser.parseStringPromise(xml);
|
||||
|
||||
const root = tcx.TrainingCenterDatabase || tcx;
|
||||
|
||||
const activities = root.Activities?.Activity
|
||||
? (
|
||||
Array.isArray(root.Activities.Activity)
|
||||
? root.Activities.Activity
|
||||
: [root.Activities.Activity]
|
||||
)
|
||||
: [];
|
||||
|
||||
const activity = activities[0] || {};
|
||||
const sport = activity.$?.Sport || null;
|
||||
|
||||
const laps = activity.Lap
|
||||
? (
|
||||
Array.isArray(activity.Lap)
|
||||
? activity.Lap
|
||||
: [activity.Lap]
|
||||
)
|
||||
: [];
|
||||
|
||||
const tracks = [];
|
||||
|
||||
for (const lap of laps) {
|
||||
const track = lap.Track
|
||||
? (
|
||||
Array.isArray(lap.Track)
|
||||
? lap.Track
|
||||
: [lap.Track]
|
||||
)
|
||||
: [];
|
||||
|
||||
tracks.push(...track);
|
||||
}
|
||||
|
||||
const points = [];
|
||||
|
||||
for (const t of tracks) {
|
||||
const tp = t.Trackpoint
|
||||
? (
|
||||
Array.isArray(t.Trackpoint)
|
||||
? t.Trackpoint
|
||||
: [t.Trackpoint]
|
||||
)
|
||||
: [];
|
||||
|
||||
points.push(...tp);
|
||||
}
|
||||
|
||||
let minLat = null;
|
||||
let maxLat = null;
|
||||
let minLon = null;
|
||||
let maxLon = null;
|
||||
let minElev = null;
|
||||
let maxElev = null;
|
||||
|
||||
let startTime = null;
|
||||
let endTime = null;
|
||||
|
||||
let totalDistance = 0;
|
||||
let totalCalories = 0;
|
||||
|
||||
let avgHr = null;
|
||||
let maxHr = null;
|
||||
|
||||
for (const lap of laps) {
|
||||
if (!startTime && lap.StartTime) {
|
||||
startTime = lap.StartTime;
|
||||
}
|
||||
|
||||
if (lap.TotalTimeSeconds && lap.StartTime) {
|
||||
const lapEnd = new Date(lap.StartTime);
|
||||
lapEnd.setSeconds(lapEnd.getSeconds() + Number(lap.TotalTimeSeconds));
|
||||
endTime = lapEnd.toISOString();
|
||||
}
|
||||
|
||||
if (lap.DistanceMeters) {
|
||||
totalDistance += Number(lap.DistanceMeters);
|
||||
}
|
||||
|
||||
if (lap.Calories) {
|
||||
totalCalories += Number(lap.Calories);
|
||||
}
|
||||
|
||||
const lapAvgHr = lap.AverageHeartRateBpm?.Value
|
||||
? Number(lap.AverageHeartRateBpm.Value)
|
||||
: null;
|
||||
|
||||
const lapMaxHr = lap.MaximumHeartRateBpm?.Value
|
||||
? Number(lap.MaximumHeartRateBpm.Value)
|
||||
: null;
|
||||
|
||||
if (Number.isFinite(lapAvgHr)) {
|
||||
avgHr = avgHr == null ? lapAvgHr : (avgHr + lapAvgHr) / 2;
|
||||
}
|
||||
|
||||
if (Number.isFinite(lapMaxHr)) {
|
||||
maxHr = maxHr == null ? lapMaxHr : Math.max(maxHr, lapMaxHr);
|
||||
}
|
||||
}
|
||||
|
||||
for (const p of points) {
|
||||
const pos = p.Position || {};
|
||||
|
||||
const lat = pos.LatitudeDegrees != null
|
||||
? Number(pos.LatitudeDegrees)
|
||||
: null;
|
||||
|
||||
const lon = pos.LongitudeDegrees != null
|
||||
? Number(pos.LongitudeDegrees)
|
||||
: null;
|
||||
|
||||
const ele = p.AltitudeMeters != null
|
||||
? Number(p.AltitudeMeters)
|
||||
: null;
|
||||
|
||||
const time = p.Time || null;
|
||||
|
||||
if (Number.isFinite(lat) && Number.isFinite(lon)) {
|
||||
minLat = minLat == null ? lat : Math.min(minLat, lat);
|
||||
maxLat = maxLat == null ? lat : Math.max(maxLat, lat);
|
||||
minLon = minLon == null ? lon : Math.min(minLon, lon);
|
||||
maxLon = maxLon == null ? lon : Math.max(maxLon, lon);
|
||||
}
|
||||
|
||||
if (Number.isFinite(ele)) {
|
||||
minElev = minElev == null ? ele : Math.min(minElev, ele);
|
||||
maxElev = maxElev == null ? ele : Math.max(maxElev, ele);
|
||||
}
|
||||
|
||||
if (time) {
|
||||
if (!startTime) startTime = time;
|
||||
endTime = time;
|
||||
}
|
||||
}
|
||||
|
||||
const start_time_millis = toMillis(startTime);
|
||||
const end_time_millis = toMillis(endTime);
|
||||
|
||||
const duration_millis =
|
||||
start_time_millis && end_time_millis
|
||||
? end_time_millis - start_time_millis
|
||||
: null;
|
||||
|
||||
const centerLat =
|
||||
minLat != null && maxLat != null
|
||||
? (minLat + maxLat) / 2
|
||||
: null;
|
||||
|
||||
const centerLon =
|
||||
minLon != null && maxLon != null
|
||||
? (minLon + maxLon) / 2
|
||||
: null;
|
||||
|
||||
const trackPreview = buildTrackPreviewFromTcxPoints(points, 300);
|
||||
|
||||
return {
|
||||
format: 'TCX',
|
||||
|
||||
display_name: activity.Notes || null,
|
||||
route_name: activity.Notes || null,
|
||||
title: activity.Notes || null,
|
||||
description: null,
|
||||
notes: activity.Notes || null,
|
||||
|
||||
sport,
|
||||
activity_type: null,
|
||||
device_manufacturer: root.Author?.Name || null,
|
||||
device_model: root.Author?.Build?.Version?.VersionMajor || null,
|
||||
source_app: root.Author?.Name || null,
|
||||
|
||||
start_time_millis,
|
||||
end_time_millis,
|
||||
duration_millis,
|
||||
|
||||
distance_meters: totalDistance || null,
|
||||
elevation_gain_meters: null,
|
||||
elevation_loss_meters: null,
|
||||
|
||||
min_elevation_meters: minElev,
|
||||
max_elevation_meters: maxElev,
|
||||
|
||||
file_distance_meters: totalDistance || null,
|
||||
file_elevation_gain_meters: null,
|
||||
|
||||
computed_distance_meters: totalDistance || null,
|
||||
computed_elevation_gain_meters: null,
|
||||
|
||||
average_speed_mps: null,
|
||||
max_speed_mps: null,
|
||||
average_pace_sec_per_km: null,
|
||||
|
||||
average_heart_rate: avgHr,
|
||||
max_heart_rate: maxHr,
|
||||
average_cadence: null,
|
||||
max_cadence: null,
|
||||
|
||||
calories: totalCalories || null,
|
||||
power_avg: null,
|
||||
power_max: null,
|
||||
|
||||
point_count: points.length,
|
||||
segment_count: tracks.length,
|
||||
lap_count: laps.length,
|
||||
|
||||
min_latitude: minLat,
|
||||
max_latitude: maxLat,
|
||||
min_longitude: minLon,
|
||||
max_longitude: maxLon,
|
||||
center_latitude: centerLat,
|
||||
center_longitude: centerLon,
|
||||
|
||||
// Preview ridotta del percorso per i thumb della gallery
|
||||
track_preview: trackPreview,
|
||||
|
||||
tags: [],
|
||||
|
||||
diagnostics: {
|
||||
source: 'TCX',
|
||||
laps: laps.length,
|
||||
tracks: tracks.length,
|
||||
points: points.length,
|
||||
track_preview_points: trackPreview.length
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = parseTcx;
|
||||
38
api_v1/scanner/parsers/parse_fit.py
Normal file
38
api_v1/scanner/parsers/parse_fit.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# parse_fit.py
|
||||
import fitdecode
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
path = sys.argv[1]
|
||||
|
||||
def to_json_safe(value):
|
||||
# Converte datetime → stringa ISO
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
|
||||
# Converte liste/tuple ricorsivamente
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [to_json_safe(v) for v in value]
|
||||
|
||||
# Converte dict ricorsivamente
|
||||
if isinstance(value, dict):
|
||||
return {k: to_json_safe(v) for k, v in value.items()}
|
||||
|
||||
# Tutto il resto viene restituito così com'è
|
||||
return value
|
||||
|
||||
frames = []
|
||||
|
||||
with fitdecode.FitReader(path) as fit:
|
||||
for frame in fit:
|
||||
if isinstance(frame, fitdecode.FitDataMessage):
|
||||
msg = {
|
||||
"name": frame.name,
|
||||
"fields": {}
|
||||
}
|
||||
for field in frame.fields:
|
||||
msg["fields"][field.name] = to_json_safe(field.value)
|
||||
frames.append(msg)
|
||||
|
||||
print(json.dumps(frames))
|
||||
348
api_v1/scanner/photoIngestQueue.js
Normal file
348
api_v1/scanner/photoIngestQueue.js
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
const TABLE = 'photo_ingest_jobs';
|
||||
|
||||
const SQLITE_BUSY_RETRY_DELAYS_MS = [
|
||||
25,
|
||||
50,
|
||||
100,
|
||||
200,
|
||||
400,
|
||||
800,
|
||||
1600,
|
||||
];
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function isSqliteBusy(error) {
|
||||
if (!error) return false;
|
||||
|
||||
if (
|
||||
error.code === 'SQLITE_BUSY' ||
|
||||
error.code === 'SQLITE_LOCKED'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const message = String(error.message || error).toLowerCase();
|
||||
|
||||
return (
|
||||
message.includes('database is locked') ||
|
||||
message.includes('database table is locked')
|
||||
);
|
||||
}
|
||||
|
||||
async function withSqliteBusyRetry(label, operation) {
|
||||
let retry = 0;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
if (
|
||||
!isSqliteBusy(error) ||
|
||||
retry >= SQLITE_BUSY_RETRY_DELAYS_MS.length
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const delay = SQLITE_BUSY_RETRY_DELAYS_MS[retry];
|
||||
retry += 1;
|
||||
|
||||
console.warn(
|
||||
`[PhotoIngestQueue] SQLITE_BUSY ${label}; ` +
|
||||
`retry=${retry}/${SQLITE_BUSY_RETRY_DELAYS_MS.length} ` +
|
||||
`delay=${delay}ms`
|
||||
);
|
||||
|
||||
await sleep(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeJobId(user, cartella, relPath) {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(JSON.stringify([user, cartella, relPath]))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
async function ensurePhotoIngestQueue(db) {
|
||||
if (!(await db.schema.hasTable(TABLE))) {
|
||||
await db.schema.createTable(TABLE, (t) => {
|
||||
t.string('id').primary();
|
||||
t.string('user').notNullable();
|
||||
t.string('cartella').notNullable();
|
||||
t.string('rel_path').notNullable();
|
||||
t.integer('source_mtime_ms').notNullable();
|
||||
t.integer('source_size_bytes').notNullable();
|
||||
t.string('status').notNullable().defaultTo('pending');
|
||||
t.integer('attempts').notNullable().defaultTo(0);
|
||||
t.integer('rescan_requested').notNullable().defaultTo(0);
|
||||
t.text('last_error').nullable();
|
||||
t.datetime('started_at').nullable();
|
||||
t.datetime('completed_at').nullable();
|
||||
t.datetime('created_at').notNullable().defaultTo(db.fn.now());
|
||||
t.datetime('updated_at').notNullable().defaultTo(db.fn.now());
|
||||
t.index(['status', 'created_at'], 'idx_photo_ingest_status_created');
|
||||
t.index(['user', 'cartella'], 'idx_photo_ingest_folder');
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await db.schema.hasColumn(TABLE, 'rescan_requested'))) {
|
||||
await db.schema.alterTable(TABLE, (t) => {
|
||||
t.integer('rescan_requested').notNullable().defaultTo(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function enqueuePhotoIngestJob(db, input) {
|
||||
const { user, cartella, relPath, mtimeMs, sizeBytes } = input;
|
||||
|
||||
if (
|
||||
typeof user !== 'string' || !user ||
|
||||
typeof cartella !== 'string' || !cartella ||
|
||||
typeof relPath !== 'string' || !relPath ||
|
||||
!Number.isFinite(mtimeMs) || mtimeMs < 0 ||
|
||||
!Number.isFinite(sizeBytes) || sizeBytes < 0
|
||||
) {
|
||||
throw new TypeError('Dati non validi per accodare la foto');
|
||||
}
|
||||
|
||||
await ensurePhotoIngestQueue(db);
|
||||
|
||||
const id = makeJobId(user, cartella, relPath);
|
||||
const mtime = Math.trunc(mtimeMs);
|
||||
const size = Math.trunc(sizeBytes);
|
||||
|
||||
return withSqliteBusyRetry('enqueue', () =>
|
||||
db.transaction(async (trx) => {
|
||||
const existing = await trx(TABLE).where({ id }).first();
|
||||
|
||||
if (existing) {
|
||||
const unchanged =
|
||||
Number(existing.source_mtime_ms) === mtime &&
|
||||
Number(existing.source_size_bytes) === size;
|
||||
|
||||
if (unchanged) {
|
||||
// Un errore non viene riavviato da eventi duplicati.
|
||||
return { id, status: existing.status, unchanged: true };
|
||||
}
|
||||
|
||||
if (existing.status === 'processing') {
|
||||
// Conserva la versione più recente richiesta.
|
||||
// Il worker finirà il lavoro corrente e poi riaccoderà.
|
||||
await trx(TABLE).where({ id, status: 'processing' }).update({
|
||||
source_mtime_ms: mtime,
|
||||
source_size_bytes: size,
|
||||
rescan_requested: 1,
|
||||
updated_at: trx.fn.now(),
|
||||
});
|
||||
|
||||
return {
|
||||
id,
|
||||
status: 'processing',
|
||||
changedWhileProcessing: true,
|
||||
};
|
||||
}
|
||||
|
||||
await trx(TABLE).where({ id }).update({
|
||||
source_mtime_ms: mtime,
|
||||
source_size_bytes: size,
|
||||
status: 'pending',
|
||||
attempts: 0,
|
||||
rescan_requested: 0,
|
||||
last_error: null,
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
updated_at: trx.fn.now(),
|
||||
});
|
||||
|
||||
return { id, status: 'pending', updated: true };
|
||||
}
|
||||
|
||||
await trx(TABLE).insert({
|
||||
id,
|
||||
user,
|
||||
cartella,
|
||||
rel_path: relPath,
|
||||
source_mtime_ms: mtime,
|
||||
source_size_bytes: size,
|
||||
status: 'pending',
|
||||
attempts: 0,
|
||||
rescan_requested: 0,
|
||||
created_at: trx.fn.now(),
|
||||
updated_at: trx.fn.now(),
|
||||
});
|
||||
|
||||
return { id, status: 'pending', inserted: true };
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function claimNextPhotoIngestJob(db) {
|
||||
await ensurePhotoIngestQueue(db);
|
||||
|
||||
return withSqliteBusyRetry('claim', () =>
|
||||
db.transaction(async (trx) => {
|
||||
const job = await trx(TABLE)
|
||||
.where({ status: 'pending' })
|
||||
.orderBy('created_at', 'asc')
|
||||
.first();
|
||||
|
||||
if (!job) return null;
|
||||
|
||||
const changed = await trx(TABLE)
|
||||
.where({ id: job.id, status: 'pending' })
|
||||
.update({
|
||||
status: 'processing',
|
||||
attempts: trx.raw('attempts + 1'),
|
||||
rescan_requested: 0,
|
||||
started_at: trx.fn.now(),
|
||||
completed_at: null,
|
||||
last_error: null,
|
||||
updated_at: trx.fn.now(),
|
||||
});
|
||||
|
||||
if (!changed) return null;
|
||||
|
||||
return {
|
||||
...job,
|
||||
status: 'processing',
|
||||
attempts: Number(job.attempts || 0) + 1,
|
||||
rescan_requested: 0,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function completePhotoIngestJob(db, id) {
|
||||
return withSqliteBusyRetry('complete', () =>
|
||||
db.transaction(async (trx) => {
|
||||
const job = await trx(TABLE)
|
||||
.where({ id, status: 'processing' })
|
||||
.first();
|
||||
|
||||
if (!job) return { updated: false };
|
||||
|
||||
if (Number(job.rescan_requested) === 1) {
|
||||
await trx(TABLE).where({ id, status: 'processing' }).update({
|
||||
status: 'pending',
|
||||
rescan_requested: 0,
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
last_error: null,
|
||||
updated_at: trx.fn.now(),
|
||||
});
|
||||
|
||||
return { updated: true, status: 'pending', requeued: true };
|
||||
}
|
||||
|
||||
await trx(TABLE).where({ id, status: 'processing' }).update({
|
||||
status: 'done',
|
||||
completed_at: trx.fn.now(),
|
||||
updated_at: trx.fn.now(),
|
||||
last_error: null,
|
||||
});
|
||||
|
||||
return { updated: true, status: 'done', requeued: false };
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function failPhotoIngestJob(db, id, error) {
|
||||
const message = String(error?.stack || error?.message || error)
|
||||
.slice(0, 12000);
|
||||
|
||||
return withSqliteBusyRetry('fail', () =>
|
||||
db.transaction(async (trx) => {
|
||||
const job = await trx(TABLE)
|
||||
.where({ id, status: 'processing' })
|
||||
.first();
|
||||
|
||||
if (!job) return { updated: false };
|
||||
|
||||
if (Number(job.rescan_requested) === 1) {
|
||||
await trx(TABLE).where({ id, status: 'processing' }).update({
|
||||
status: 'pending',
|
||||
rescan_requested: 0,
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
last_error: message,
|
||||
updated_at: trx.fn.now(),
|
||||
});
|
||||
|
||||
return { updated: true, status: 'pending', requeued: true };
|
||||
}
|
||||
|
||||
await trx(TABLE).where({ id, status: 'processing' }).update({
|
||||
status: 'error',
|
||||
last_error: message,
|
||||
completed_at: trx.fn.now(),
|
||||
updated_at: trx.fn.now(),
|
||||
});
|
||||
|
||||
return { updated: true, status: 'error', requeued: false };
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function resetStalePhotoIngestJobs(db, staleMinutes = 30) {
|
||||
await ensurePhotoIngestQueue(db);
|
||||
|
||||
const cutoff = new Date(Date.now() - staleMinutes * 60 * 1000)
|
||||
.toISOString()
|
||||
.replace('T', ' ')
|
||||
.replace(/\.\d{3}Z$/, '');
|
||||
|
||||
return db(TABLE)
|
||||
.where({ status: 'processing' })
|
||||
.whereNotNull('started_at')
|
||||
.where('started_at', '<', cutoff)
|
||||
.update({
|
||||
status: 'pending',
|
||||
rescan_requested: 0,
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
last_error: 'Recuperato dopo interruzione del worker',
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
}
|
||||
|
||||
async function getPhotoIngestQueueCounts(db) {
|
||||
await ensurePhotoIngestQueue(db);
|
||||
|
||||
const rows = await db(TABLE)
|
||||
.select('status')
|
||||
.count({ total: '*' })
|
||||
.groupBy('status');
|
||||
|
||||
const counts = {
|
||||
pending: 0,
|
||||
processing: 0,
|
||||
done: 0,
|
||||
error: 0,
|
||||
};
|
||||
|
||||
for (const row of rows) {
|
||||
if (Object.hasOwn(counts, row.status)) {
|
||||
counts[row.status] = Number(row.total);
|
||||
}
|
||||
}
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ensurePhotoIngestQueue,
|
||||
enqueuePhotoIngestJob,
|
||||
claimNextPhotoIngestJob,
|
||||
completePhotoIngestJob,
|
||||
failPhotoIngestJob,
|
||||
resetStalePhotoIngestJobs,
|
||||
getPhotoIngestQueueCounts,
|
||||
};
|
||||
263
api_v1/scanner/photoIngestWorker.js
Normal file
263
api_v1/scanner/photoIngestWorker.js
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs/promises');
|
||||
|
||||
const { WEB_ROOT, SUPPORTED_EXTS } = require('../config');
|
||||
const scanPhotoSingle = require('./scanPhotoSingle');
|
||||
const {
|
||||
enqueueServerEvent,
|
||||
} = require('../../db/serverEventOutbox');
|
||||
const { sha256 } = require('./utils');
|
||||
|
||||
const {
|
||||
ensurePhotoIngestQueue,
|
||||
claimNextPhotoIngestJob,
|
||||
enqueuePhotoIngestJob,
|
||||
completePhotoIngestJob,
|
||||
failPhotoIngestJob,
|
||||
resetStalePhotoIngestJobs,
|
||||
} = require('./photoIngestQueue');
|
||||
|
||||
function sameVersion(stat, job) {
|
||||
return (
|
||||
Math.trunc(stat.mtimeMs) === Math.trunc(Number(job.source_mtime_ms)) &&
|
||||
stat.size === Number(job.source_size_bytes)
|
||||
);
|
||||
}
|
||||
|
||||
function isInside(parent, child) {
|
||||
const relative = path.relative(parent, child);
|
||||
return relative === '' ||
|
||||
(!relative.startsWith(`..${path.sep}`) &&
|
||||
relative !== '..' &&
|
||||
!path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
async function resolveJobFile(job) {
|
||||
for (const value of [job.user, job.cartella, job.rel_path]) {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
throw new Error('Percorso del job non valido');
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
path.isAbsolute(job.rel_path) ||
|
||||
job.rel_path.includes('\\') ||
|
||||
job.rel_path.split('/').some(part =>
|
||||
!part || part === '.' || part === '..'
|
||||
)
|
||||
) {
|
||||
throw new Error('Percorso relativo non sicuro');
|
||||
}
|
||||
|
||||
if (
|
||||
job.user === '.' || job.user === '..' ||
|
||||
job.cartella === '.' || job.cartella === '..' ||
|
||||
job.user.includes('/') || job.user.includes('\\') ||
|
||||
job.cartella.includes('/') || job.cartella.includes('\\')
|
||||
) {
|
||||
throw new Error('Utente o cartella non validi');
|
||||
}
|
||||
|
||||
const photosRoot = path.resolve(
|
||||
__dirname, '..', '..', WEB_ROOT, 'photos'
|
||||
);
|
||||
|
||||
const originalRoot = path.resolve(
|
||||
photosRoot, job.user, 'original', job.cartella
|
||||
);
|
||||
|
||||
const absPath = path.resolve(originalRoot, ...job.rel_path.split('/'));
|
||||
|
||||
if (!isInside(originalRoot, absPath)) {
|
||||
throw new Error('Il file esce dalla cartella originale prevista');
|
||||
}
|
||||
|
||||
const realRoot = await fs.realpath(originalRoot);
|
||||
const realFile = await fs.realpath(absPath);
|
||||
|
||||
if (!isInside(realRoot, realFile)) {
|
||||
throw new Error('Il file risolve fuori dalla cartella originale');
|
||||
}
|
||||
|
||||
const ext = path.extname(absPath).toLowerCase();
|
||||
|
||||
if (!SUPPORTED_EXTS.has(ext)) {
|
||||
throw new Error(`Estensione non supportata: ${ext}`);
|
||||
}
|
||||
|
||||
const stat = await fs.stat(realFile);
|
||||
|
||||
if (!stat.isFile()) {
|
||||
throw new Error('Il percorso non è un file regolare');
|
||||
}
|
||||
|
||||
return {
|
||||
id: sha256(`${job.user}/${job.cartella}/${job.rel_path}`),
|
||||
user: job.user,
|
||||
cartella: job.cartella,
|
||||
name: path.basename(absPath),
|
||||
relPath: job.rel_path,
|
||||
absPath: realFile,
|
||||
ext,
|
||||
stat,
|
||||
path: `/photos/${job.user}/original/${job.cartella}/${job.rel_path}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function runOneJob(db) {
|
||||
await ensurePhotoIngestQueue(db);
|
||||
|
||||
const job = await claimNextPhotoIngestJob(db);
|
||||
|
||||
if (!job) {
|
||||
return { processed: false };
|
||||
}
|
||||
|
||||
console.log(`[PhotoIngest] Avvio job ${job.id}: ${job.rel_path}`);
|
||||
|
||||
try {
|
||||
const file = await resolveJobFile(job);
|
||||
|
||||
// Se la versione è già cambiata, aggiorna il job mentre è
|
||||
// processing: completePhotoIngestJob lo riporterà a pending.
|
||||
if (!sameVersion(file.stat, job)) {
|
||||
await enqueuePhotoIngestJob(db, {
|
||||
user: job.user,
|
||||
cartella: job.cartella,
|
||||
relPath: job.rel_path,
|
||||
mtimeMs: file.stat.mtimeMs,
|
||||
sizeBytes: file.stat.size,
|
||||
});
|
||||
|
||||
const result = await completePhotoIngestJob(db, job.id);
|
||||
return {
|
||||
processed: true,
|
||||
status: result.status,
|
||||
requeued: Boolean(result.requeued),
|
||||
};
|
||||
}
|
||||
|
||||
const previous = await db('photos')
|
||||
.select('id', 'size_bytes', 'mtimeMs', 'fast_hash', 'path')
|
||||
.where({ id: file.id })
|
||||
.first();
|
||||
|
||||
const scannedPhoto = await scanPhotoSingle(
|
||||
db,
|
||||
job.user,
|
||||
job.cartella,
|
||||
file,
|
||||
[],
|
||||
'',
|
||||
previous || null
|
||||
);
|
||||
|
||||
// Se il file è scomparso, non eliminare il record fotografico.
|
||||
// Il job va in errore e l'evento di cancellazione resta responsabilità
|
||||
// del watcher.
|
||||
const statAfter = await fs.stat(file.absPath);
|
||||
|
||||
// Se è cambiato durante la lavorazione, aggiorna la versione in coda
|
||||
// prima di completare il job: rescan_requested garantisce il retry.
|
||||
if (
|
||||
Math.trunc(statAfter.mtimeMs) !== Math.trunc(file.stat.mtimeMs) ||
|
||||
statAfter.size !== file.stat.size
|
||||
) {
|
||||
await enqueuePhotoIngestJob(db, {
|
||||
user: job.user,
|
||||
cartella: job.cartella,
|
||||
relPath: job.rel_path,
|
||||
mtimeMs: statAfter.mtimeMs,
|
||||
sizeBytes: statAfter.size,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await completePhotoIngestJob(db, job.id);
|
||||
|
||||
console.log(
|
||||
`[PhotoIngest] Job ${job.id} completato; stato: ${result.status}`
|
||||
);
|
||||
|
||||
/*
|
||||
* Notifica una foto realmente nuova soltanto dopo che il
|
||||
* job e' stato completato definitivamente.
|
||||
*
|
||||
* IMPORTANTE:
|
||||
* qui NON viene importato ws-server.
|
||||
* Il daemon scrive soltanto nella outbox SQLite.
|
||||
*/
|
||||
if (
|
||||
!previous &&
|
||||
!result.requeued &&
|
||||
result.status === 'done' &&
|
||||
scannedPhoto &&
|
||||
scannedPhoto.id
|
||||
) {
|
||||
await enqueueServerEvent(db, {
|
||||
channel: 'photo',
|
||||
user: job.user,
|
||||
payload: {
|
||||
type: 'added',
|
||||
id: scannedPhoto.id,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[PhotoIngest] Evento gallery accodato photo=${scannedPhoto.id}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
processed: true,
|
||||
status: result.status,
|
||||
requeued: Boolean(result.requeued),
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(`[PhotoIngest] Errore job ${job.id}:`, err.message);
|
||||
|
||||
try {
|
||||
await failPhotoIngestJob(db, job.id, err.message);
|
||||
} catch (queueErr) {
|
||||
console.error(
|
||||
'[PhotoIngest] Errore aggiornando la coda:',
|
||||
queueErr.message
|
||||
);
|
||||
}
|
||||
|
||||
return { processed: true, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function runWorker(db, options = {}) {
|
||||
const pollMs = Math.max(250, Number(options.pollMs) || 2000);
|
||||
const staleMinutes = Math.max(
|
||||
1, Number(options.staleMinutes) || 30
|
||||
);
|
||||
|
||||
await ensurePhotoIngestQueue(db);
|
||||
await resetStalePhotoIngestJobs(db, staleMinutes);
|
||||
|
||||
console.log(`[PhotoIngest] Worker avviato; polling ${pollMs} ms`);
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const result = await runOneJob(db);
|
||||
|
||||
if (!result.processed) {
|
||||
await new Promise(resolve => setTimeout(resolve, pollMs));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[PhotoIngest] Errore worker:', err);
|
||||
await new Promise(resolve => setTimeout(resolve, pollMs));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
runOneJob,
|
||||
runWorker,
|
||||
resolveJobFile,
|
||||
sameVersion,
|
||||
};
|
||||
91
api_v1/scanner/photoScanProgress.js
Normal file
91
api_v1/scanner/photoScanProgress.js
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
'use strict';
|
||||
|
||||
const photoIngestQueue = require('./photoIngestQueue');
|
||||
|
||||
let epoch = null;
|
||||
|
||||
async function beginGlobalPhotoScan(db) {
|
||||
if (!epoch || !epoch.active) {
|
||||
const created = {
|
||||
active: true,
|
||||
baselineDone: 0,
|
||||
enumerationsInProgress: 0,
|
||||
initPromise: null,
|
||||
};
|
||||
|
||||
epoch = created;
|
||||
created.initPromise = photoIngestQueue
|
||||
.getPhotoIngestQueueCounts(db)
|
||||
.then(counts => {
|
||||
created.baselineDone = Number(counts.done || 0);
|
||||
})
|
||||
.catch(err => {
|
||||
if (epoch === created) created.active = false;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
const current = epoch;
|
||||
current.enumerationsInProgress++;
|
||||
try {
|
||||
await current.initPromise;
|
||||
} catch (err) {
|
||||
current.enumerationsInProgress = Math.max(
|
||||
0, current.enumerationsInProgress - 1
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function finishGlobalPhotoScan() {
|
||||
if (epoch) {
|
||||
epoch.enumerationsInProgress = Math.max(
|
||||
0, epoch.enumerationsInProgress - 1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function getGlobalPhotoScanStatus(db) {
|
||||
const counts = await photoIngestQueue.getPhotoIngestQueueCounts(db);
|
||||
const pending = Number(counts.pending || 0);
|
||||
const processing = Number(counts.processing || 0);
|
||||
const done = Number(counts.done || 0);
|
||||
const errors = Number(counts.error || 0);
|
||||
const remaining = pending + processing;
|
||||
const totalCurrent = pending + processing + done + errors;
|
||||
|
||||
let current;
|
||||
let total;
|
||||
let percent;
|
||||
let baselineDone = done;
|
||||
|
||||
if (epoch && epoch.active) {
|
||||
baselineDone = epoch.baselineDone;
|
||||
total = Math.max(0, totalCurrent - baselineDone);
|
||||
current = Math.max(0, (done - baselineDone) + errors);
|
||||
percent = total <= 0
|
||||
? 100
|
||||
: Math.max(0, Math.min(100, Math.round(current / total * 100)));
|
||||
|
||||
// Chiude il ciclo solo quando tutte le richieste di scansione
|
||||
// hanno terminato l'accodamento e la coda è vuota.
|
||||
if (epoch.enumerationsInProgress === 0 && remaining === 0) {
|
||||
epoch.active = false;
|
||||
}
|
||||
} else {
|
||||
total = totalCurrent;
|
||||
current = totalCurrent;
|
||||
percent = 100;
|
||||
}
|
||||
|
||||
return {
|
||||
pending, processing, done, errors, remaining,
|
||||
current, total, percent, baselineDone,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
beginGlobalPhotoScan,
|
||||
finishGlobalPhotoScan,
|
||||
getGlobalPhotoScanStatus,
|
||||
};
|
||||
24
api_v1/scanner/postWithAuth.js
Normal file
24
api_v1/scanner/postWithAuth.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
// scanner/postWithAuth.js
|
||||
// Versione locale: niente HTTP, niente token, solo DB
|
||||
|
||||
module.exports = function createPostToDB(db) {
|
||||
|
||||
/**
|
||||
* Inserisce o aggiorna un record nel DB SQLite
|
||||
* (sostituisce completamente axios.post)
|
||||
*/
|
||||
async function postToDB(record) {
|
||||
if (!record || !record.id) {
|
||||
throw new Error("Record non valido");
|
||||
}
|
||||
|
||||
await db('photos')
|
||||
.insert(record)
|
||||
.onConflict('id')
|
||||
.merge();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return postToDB;
|
||||
};
|
||||
188
api_v1/scanner/processActivityFile.js
Normal file
188
api_v1/scanner/processActivityFile.js
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
// api_v1/scanner/processActivityFile.js
|
||||
const path = require('path');
|
||||
const fsp = require('fs/promises');
|
||||
const { sha256 } = require('./utils');
|
||||
const { log } = require('./logger');
|
||||
const parseActivityFile = require('./parseActivityFile'); // 🔥 parser FIT/GPX/TCX
|
||||
const { WEB_ROOT, PATH_FULL } = require('../config');
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// processActivityFile → equivalente di processFile per le attività
|
||||
// ---------------------------------------------------------
|
||||
async function processActivityFile(userName, cartella, fileRelPath, absPath, ext, st) {
|
||||
|
||||
// 🔥 PARSING FIT/GPX/TCX
|
||||
const parsed = await parseActivityFile(absPath);
|
||||
|
||||
// parsed contiene:
|
||||
// {
|
||||
// file_name,
|
||||
// file_extension,
|
||||
// format,
|
||||
// display_name,
|
||||
// route_name,
|
||||
// title,
|
||||
// description,
|
||||
// notes,
|
||||
// sport,
|
||||
// activity_type,
|
||||
// device_manufacturer,
|
||||
// device_model,
|
||||
// source_app,
|
||||
// start_time_millis,
|
||||
// end_time_millis,
|
||||
// duration_millis,
|
||||
// distance_meters,
|
||||
// elevation_gain_meters,
|
||||
// elevation_loss_meters,
|
||||
// min_elevation_meters,
|
||||
// max_elevation_meters,
|
||||
// file_distance_meters,
|
||||
// file_elevation_gain_meters,
|
||||
// computed_distance_meters,
|
||||
// computed_elevation_gain_meters,
|
||||
// average_speed_mps,
|
||||
// max_speed_mps,
|
||||
// average_pace_sec_per_km,
|
||||
// average_heart_rate,
|
||||
// max_heart_rate,
|
||||
// average_cadence,
|
||||
// max_cadence,
|
||||
// calories,
|
||||
// power_avg,
|
||||
// power_max,
|
||||
// point_count,
|
||||
// segment_count,
|
||||
// lap_count,
|
||||
// min_latitude,
|
||||
// max_latitude,
|
||||
// min_longitude,
|
||||
// max_longitude,
|
||||
// center_latitude,
|
||||
// center_longitude,
|
||||
// tags,
|
||||
// diagnostics
|
||||
// }
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// GPS
|
||||
//
|
||||
// I dati altimetrici dell'attività provengono direttamente
|
||||
// dal file FIT, GPX o TCX. Il reverse geocoding viene
|
||||
// completato successivamente dal geocode worker.
|
||||
// ---------------------------------------------------------
|
||||
let gps = null;
|
||||
|
||||
if (
|
||||
parsed.center_latitude != null
|
||||
&& parsed.center_longitude != null
|
||||
) {
|
||||
gps = {
|
||||
lat: parsed.center_latitude,
|
||||
lng: parsed.center_longitude,
|
||||
alt: parsed.min_elevation_meters ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const location = null;
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// PATH FULL / RELATIVI
|
||||
// ---------------------------------------------------------
|
||||
const relPath = fileRelPath;
|
||||
|
||||
const fullPath = PATH_FULL
|
||||
? path.posix.join('/activities', userName, cartella, fileRelPath)
|
||||
: relPath;
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// ID deterministico
|
||||
// ---------------------------------------------------------
|
||||
const id = sha256(`${userName}/${cartella}/${fileRelPath}`);
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// COSTRUZIONE META (identico a normalizeActivity)
|
||||
// ---------------------------------------------------------
|
||||
return {
|
||||
id,
|
||||
user: userName,
|
||||
cartella,
|
||||
taken_at: new Date(parsed.start_time_millis).toISOString(),
|
||||
file_name: parsed.file_name,
|
||||
file_extension: parsed.file_extension,
|
||||
format: parsed.format,
|
||||
display_name: parsed.display_name,
|
||||
route_name: parsed.route_name,
|
||||
title: parsed.title,
|
||||
description: parsed.description,
|
||||
notes: parsed.notes,
|
||||
sport: parsed.sport,
|
||||
activity_type: parsed.activity_type,
|
||||
device_manufacturer: parsed.device_manufacturer,
|
||||
device_model: parsed.device_model,
|
||||
source_app: parsed.source_app,
|
||||
|
||||
start_time_millis: parsed.start_time_millis,
|
||||
end_time_millis: parsed.end_time_millis,
|
||||
duration_millis: parsed.duration_millis,
|
||||
|
||||
distance_meters: parsed.distance_meters,
|
||||
elevation_gain_meters: parsed.elevation_gain_meters,
|
||||
elevation_loss_meters: parsed.elevation_loss_meters,
|
||||
|
||||
min_elevation_meters: parsed.min_elevation_meters,
|
||||
max_elevation_meters: parsed.max_elevation_meters,
|
||||
|
||||
file_distance_meters: parsed.file_distance_meters,
|
||||
file_elevation_gain_meters: parsed.file_elevation_gain_meters,
|
||||
|
||||
computed_distance_meters: parsed.computed_distance_meters,
|
||||
computed_elevation_gain_meters: parsed.computed_elevation_gain_meters,
|
||||
|
||||
average_speed_mps: parsed.average_speed_mps,
|
||||
max_speed_mps: parsed.max_speed_mps,
|
||||
average_pace_sec_per_km: parsed.average_pace_sec_per_km,
|
||||
|
||||
average_heart_rate: parsed.average_heart_rate,
|
||||
max_heart_rate: parsed.max_heart_rate,
|
||||
average_cadence: parsed.average_cadence,
|
||||
max_cadence: parsed.max_cadence,
|
||||
|
||||
calories: parsed.calories,
|
||||
power_avg: parsed.power_avg,
|
||||
power_max: parsed.power_max,
|
||||
|
||||
point_count: parsed.point_count,
|
||||
segment_count: parsed.segment_count,
|
||||
lap_count: parsed.lap_count,
|
||||
|
||||
min_latitude: parsed.min_latitude,
|
||||
max_latitude: parsed.max_latitude,
|
||||
min_longitude: parsed.min_longitude,
|
||||
max_longitude: parsed.max_longitude,
|
||||
center_latitude: parsed.center_latitude,
|
||||
center_longitude: parsed.center_longitude,
|
||||
|
||||
size_bytes: st.size,
|
||||
last_modified_millis: st.mtimeMs,
|
||||
date_added_millis: Date.now(),
|
||||
date_modified_millis: Date.now(),
|
||||
last_scan_millis: Date.now(),
|
||||
|
||||
is_favorite: false,
|
||||
is_hidden: false,
|
||||
rating: 0,
|
||||
|
||||
tags: parsed.tags || [],
|
||||
track_preview: parsed.track_preview || [],
|
||||
scan_status: "ok",
|
||||
scan_error: null,
|
||||
diagnostics: parsed.diagnostics || null,
|
||||
|
||||
gps,
|
||||
location,
|
||||
path: fullPath
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = processActivityFile;
|
||||
224
api_v1/scanner/processFile.js
Normal file
224
api_v1/scanner/processFile.js
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
const path = require("path");
|
||||
const fsp = require("fs/promises");
|
||||
const ExifReader = require("exifreader");
|
||||
const sharp = require("sharp");
|
||||
const { sha256, inferMimeFromExt, parseExifDateUtc } = require("./utils");
|
||||
const { extractGpsFromExif, extractGpsWithExiftool } = require("./gps");
|
||||
const { createVideoThumbnail, createThumbnails } = require("./thumbs");
|
||||
const { probeVideo } = require("./video");
|
||||
const { WEB_ROOT, PATH_FULL } = require("../config");
|
||||
|
||||
async function processFile(userName, cartella, fileRelPath, absPath, ext, st) {
|
||||
const isVideo = [".mp4", ".mov", ".m4v"].includes(ext);
|
||||
|
||||
const thumbBase = path.join(
|
||||
WEB_ROOT,
|
||||
"photos",
|
||||
userName,
|
||||
"thumbs",
|
||||
cartella,
|
||||
path.dirname(fileRelPath),
|
||||
);
|
||||
|
||||
await fsp.mkdir(thumbBase, { recursive: true });
|
||||
|
||||
// --- Nome file + estensione (dal path ASSOLUTO, sempre corretto) ---
|
||||
const parsed = path.parse(absPath);
|
||||
const baseName = parsed.name; // IMG_0249
|
||||
const extName = parsed.ext; // .JPG
|
||||
|
||||
const absThumbMin = path.join(thumbBase, `${baseName}_min.jpg`);
|
||||
const absThumbAvg = path.join(thumbBase, `${baseName}_avg.jpg`);
|
||||
const thumbStart = process.hrtime.bigint();
|
||||
if (isVideo) {
|
||||
await createVideoThumbnail(absPath, absThumbMin, absThumbAvg);
|
||||
} else {
|
||||
await createThumbnails(absPath, absThumbMin, absThumbAvg);
|
||||
}
|
||||
const thumbMs =
|
||||
Number(process.hrtime.bigint() - thumbStart) / 1e6;
|
||||
|
||||
console.log(
|
||||
`⏱️ [PROFILE processFile] ${path.basename(absPath)} thumbnails=${thumbMs.toFixed(2)} ms`
|
||||
);
|
||||
const exifStart = process.hrtime.bigint();
|
||||
|
||||
// --- EXIF ---
|
||||
let tags = {};
|
||||
try {
|
||||
tags = await ExifReader.load(absPath, { expanded: true });
|
||||
} catch {}
|
||||
|
||||
let timeRaw = tags?.exif?.DateTimeOriginal?.value?.[0] || null;
|
||||
|
||||
const exifMs =
|
||||
Number(process.hrtime.bigint() - exifStart) / 1e6;
|
||||
|
||||
console.log(
|
||||
`⏱️ [PROFILE processFile] ${path.basename(absPath)} exif=${exifMs.toFixed(2)} ms`
|
||||
);
|
||||
|
||||
let takenAtIso = parseExifDateUtc(timeRaw);
|
||||
|
||||
// Fallback per i video
|
||||
if (isVideo) {
|
||||
const info = await probeVideo(absPath);
|
||||
|
||||
// Cerca la data nei punti standard
|
||||
const creationFormat = info.format?.tags?.creation_time || null;
|
||||
const creationStream0 = info.streams?.[0]?.tags?.creation_time || null;
|
||||
const creationStream1 = info.streams?.[1]?.tags?.creation_time || null;
|
||||
|
||||
// Scegli la prima disponibile
|
||||
const videoDate =
|
||||
creationFormat || creationStream0 || creationStream1 || null;
|
||||
|
||||
if (videoDate) {
|
||||
// NON convertire, NON usare new Date()
|
||||
takenAtIso = videoDate;
|
||||
timeRaw = videoDate;
|
||||
} else {
|
||||
// Fallback finale: mtime del file
|
||||
const fallback = new Date(st.mtimeMs).toISOString();
|
||||
takenAtIso = fallback;
|
||||
timeRaw = fallback;
|
||||
}
|
||||
}
|
||||
|
||||
// --- GPS ---
|
||||
let gps = null;
|
||||
|
||||
if (isVideo) {
|
||||
// i video usano exiftool
|
||||
gps = await extractGpsWithExiftool(absPath);
|
||||
} else {
|
||||
// le foto usano exifreader
|
||||
gps = extractGpsFromExif(tags);
|
||||
}
|
||||
|
||||
// L'altitudine EXIF viene mantenuta.
|
||||
// Se assente, sarà recuperata dal geocode worker.
|
||||
|
||||
// --- DIMENSIONI & ROTAZIONE ---
|
||||
let width = null,
|
||||
height = null,
|
||||
duration = null,
|
||||
duration_ms = null,
|
||||
rotation = 0;
|
||||
|
||||
if (isVideo) {
|
||||
const info = await probeVideo(absPath);
|
||||
const stream = info.streams?.find((s) => s.width && s.height);
|
||||
|
||||
if (stream) {
|
||||
width = stream.width;
|
||||
height = stream.height;
|
||||
|
||||
rotation = 0;
|
||||
|
||||
if (stream?.tags?.rotate) {
|
||||
rotation = Number(stream.tags.rotate);
|
||||
}
|
||||
|
||||
const sdl = stream?.side_data_list;
|
||||
if (sdl && Array.isArray(sdl)) {
|
||||
const rotEntry = sdl.find((d) => d.rotation !== undefined);
|
||||
if (rotEntry) rotation = Number(rotEntry.rotation);
|
||||
|
||||
const matrixEntry = sdl.find(
|
||||
(d) => typeof d.displaymatrix === "string",
|
||||
);
|
||||
if (matrixEntry) {
|
||||
const match = matrixEntry.displaymatrix.match(
|
||||
/rotation of ([\-0-9]+) degrees/i,
|
||||
);
|
||||
if (match) rotation = Number(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
rotation = ((rotation % 360) + 360) % 360;
|
||||
}
|
||||
|
||||
duration = info.format?.duration || null;
|
||||
duration_ms = duration ? Math.round(duration * 1000) : null;
|
||||
} else {
|
||||
const metadataStart = process.hrtime.bigint();
|
||||
|
||||
try {
|
||||
const meta = await sharp(absPath).metadata();
|
||||
width = meta.width || null;
|
||||
height = meta.height || null;
|
||||
} catch {}
|
||||
|
||||
const metadataMs =
|
||||
Number(process.hrtime.bigint() - metadataStart) / 1e6;
|
||||
|
||||
console.log(
|
||||
`⏱️ [PROFILE processFile] ${path.basename(absPath)} metadata=${metadataMs.toFixed(2)} ms`
|
||||
);
|
||||
|
||||
try {
|
||||
const raw =
|
||||
tags?.exif?.Orientation?.value ??
|
||||
tags?.image?.Orientation?.value ??
|
||||
tags?.ifd0?.Orientation?.value ??
|
||||
null;
|
||||
|
||||
const val = Array.isArray(raw) ? raw[0] : raw;
|
||||
|
||||
const map = { 1: 0, 3: 180, 6: 90, 8: 270 };
|
||||
rotation = map[val] ?? 0;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const mime_type = inferMimeFromExt(ext);
|
||||
const id = sha256(`${userName}/${cartella}/${fileRelPath}`);
|
||||
|
||||
// Reverse geocoding eseguito in modo asincrono.
|
||||
const location = null;
|
||||
|
||||
//
|
||||
// --- GESTIONE PATH FULL / RELATIVI ---
|
||||
//
|
||||
|
||||
const relPath = fileRelPath;
|
||||
const relThub1 = fileRelPath.replace(/\.[^.]+$/, "_min.jpg");
|
||||
const relThub2 = fileRelPath.replace(/\.[^.]+$/, "_avg.jpg");
|
||||
const fullPath = PATH_FULL
|
||||
? path.posix.join("/photos", userName, cartella, fileRelPath)
|
||||
: relPath;
|
||||
|
||||
const fullThub1 = PATH_FULL
|
||||
? path.posix.join("/photos", userName, "thumbs", cartella, relThub1)
|
||||
: relThub1;
|
||||
|
||||
const fullThub2 = PATH_FULL
|
||||
? path.posix.join("/photos", userName, "thumbs", cartella, relThub2)
|
||||
: relThub2;
|
||||
|
||||
const fullCast = "";
|
||||
|
||||
return {
|
||||
id,
|
||||
user: userName,
|
||||
cartella,
|
||||
name: baseName + extName, // 👈 SEMPRE CORRETTO
|
||||
path: fullPath,
|
||||
thub1: fullThub1,
|
||||
thub2: fullThub2,
|
||||
cast: fullCast,
|
||||
gps,
|
||||
data: timeRaw,
|
||||
taken_at: takenAtIso,
|
||||
mime_type,
|
||||
width,
|
||||
height,
|
||||
rotation,
|
||||
size_bytes: st.size,
|
||||
mtimeMs: st.mtimeMs,
|
||||
duration_ms: isVideo ? duration_ms : null,
|
||||
location,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = processFile;
|
||||
97
api_v1/scanner/recordChange.js
Normal file
97
api_v1/scanner/recordChange.js
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// api_v1/scanner/recordChange.js
|
||||
const { log } = require('./logger');
|
||||
const wss = require('../../ws-server'); // IMPORTANTE
|
||||
|
||||
async function recordChange(db, photo_id, user, change_type) {
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
// -----------------------------------------------------
|
||||
// DEDUPLICA SOLO PER "removed"
|
||||
// -----------------------------------------------------
|
||||
if (change_type === 'removed') {
|
||||
const already = await db('photo_changes')
|
||||
.where({ photo_id, user, change_type: 'removed' })
|
||||
.first();
|
||||
|
||||
if (already) {
|
||||
log(`📘 [CHANGES] removed già registrato → ${photo_id}, ma invio comunque WS`);
|
||||
|
||||
// -----------------------------------------------------
|
||||
// 🔥 INVIA WS ANCHE SE GIÀ REGISTRATO
|
||||
// -----------------------------------------------------
|
||||
|
||||
console.log("WS → invio evento (USER) [removed già registrato]");
|
||||
try {
|
||||
wss.broadcastToUser(user, {
|
||||
type: "removed",
|
||||
id: photo_id
|
||||
});
|
||||
console.log("WS → OK USER (removed già registrato)");
|
||||
} catch (e) {
|
||||
console.error("WS → ERRORE USER (removed già registrato):", e);
|
||||
}
|
||||
|
||||
console.log("WS → invio evento (ADMIN) [removed già registrato]");
|
||||
try {
|
||||
wss.broadcastToAdmins({
|
||||
type: "removed",
|
||||
id: photo_id,
|
||||
user
|
||||
});
|
||||
console.log("WS → OK ADMIN (removed già registrato)");
|
||||
} catch (e) {
|
||||
console.error("WS → ERRORE ADMIN (removed già registrato):", e);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------
|
||||
// SALVA NEL DB (added o primo removed)
|
||||
// -----------------------------------------------------
|
||||
await db("photo_changes").insert({
|
||||
photo_id,
|
||||
user,
|
||||
change_type,
|
||||
timestamp
|
||||
});
|
||||
|
||||
log(`📘 [CHANGES] ${change_type} → ${photo_id}`);
|
||||
|
||||
// -----------------------------------------------------
|
||||
// LOG PRIMA DEL BROADCAST
|
||||
// -----------------------------------------------------
|
||||
console.log("WS → invio evento:", { user, change_type, photo_id });
|
||||
|
||||
// -----------------------------------------------------
|
||||
// 🔥 BROADCAST A USER
|
||||
// -----------------------------------------------------
|
||||
console.log("WS → invio evento (USER)");
|
||||
try {
|
||||
wss.broadcastToUser(user, {
|
||||
type: change_type,
|
||||
id: photo_id
|
||||
});
|
||||
console.log("WS → OK USER");
|
||||
} catch (e) {
|
||||
console.error("WS → ERRORE USER:", e);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------
|
||||
// 🔥 BROADCAST A ADMIN
|
||||
// -----------------------------------------------------
|
||||
console.log("WS → invio evento (ADMIN)");
|
||||
try {
|
||||
wss.broadcastToAdmins({
|
||||
type: change_type,
|
||||
id: photo_id,
|
||||
user
|
||||
});
|
||||
console.log("WS → OK ADMIN");
|
||||
} catch (e) {
|
||||
console.error("WS → ERRORE ADMIN:", e);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = recordChange;
|
||||
249
api_v1/scanner/scan.md
Normal file
249
api_v1/scanner/scan.md
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
scanPhotosUser(userName, db)
|
||||
│
|
||||
├── individua:
|
||||
│ photos/<user>/original/
|
||||
│
|
||||
├── legge le entry dentro original/
|
||||
│
|
||||
├── prende SOLO le directory
|
||||
│
|
||||
├── countFilesUser(userDir)
|
||||
│ └── ricorsivamente guarda tutti i file
|
||||
│ └── conta solo quelli con estensione in SUPPORTED_EXTS
|
||||
│
|
||||
└── per ogni cartella:
|
||||
scanPhoto(cartella, userName, db, ...)
|
||||
│
|
||||
└── QUI viene probabilmente
|
||||
deciso cosa fare col singolo file
|
||||
|
||||
scanPhotosUser
|
||||
│
|
||||
│ per ogni cartella
|
||||
▼
|
||||
scanPhoto()
|
||||
│
|
||||
▼
|
||||
scanPhotoCartella()
|
||||
│
|
||||
└── QUI probabilmente avviene la decisione sul file
|
||||
|
||||
Quindi scanPhoto():
|
||||
costruisce il percorso:
|
||||
const absCartella = path.join(userDir, cartella);
|
||||
passa la cartella a scanPhotoCartella().
|
||||
scanPhotoCartella() riempie newFiles.
|
||||
solo dopo, scanPhoto() manda al server i file presenti in newFiles:
|
||||
|
||||
|
||||
scanCartella.js non decide se la foto è già stata scansionata: decide solo quali file sono candidati alla scansione.
|
||||
|
||||
cartella
|
||||
↓
|
||||
walk() ricorsivo
|
||||
↓
|
||||
è directory?
|
||||
├─ sì → entra dentro
|
||||
└─ no
|
||||
↓
|
||||
estensione supportata?
|
||||
├─ no → SKIP
|
||||
└─ sì
|
||||
↓
|
||||
crea ID deterministico
|
||||
↓
|
||||
stat() del file
|
||||
↓
|
||||
yield metadata
|
||||
|
||||
Il valore di SUPPORTED_EXTS è quindi importante. Lo trovi in:
|
||||
api_v1/config.js
|
||||
La cosa ancora più interessante: l'ID
|
||||
Per ogni file accettato crea:
|
||||
|
||||
const id = sha256(`${userName}/${cartella}/${fileRelPath}`);
|
||||
|
||||
Quindi l'ID non dipende dal contenuto della foto.
|
||||
Dipende da:
|
||||
user
|
||||
+
|
||||
cartella
|
||||
+
|
||||
percorso relativo
|
||||
|
||||
|
||||
scanPhotoUser
|
||||
↓
|
||||
scanPhoto
|
||||
↓
|
||||
scanPhotoCartella
|
||||
↓
|
||||
scanCartella
|
||||
↓
|
||||
per ogni file con estensione supportata
|
||||
↓
|
||||
scanPhotoSingle(...)
|
||||
↓
|
||||
QUI viene probabilmente deciso:
|
||||
├── già presente → non fare certe operazioni
|
||||
├── nuovo → processa
|
||||
└── modificato → eventualmente aggiorna
|
||||
|
||||
Quindi non è scanPhotoCartella a decidere se processare o meno la foto.
|
||||
È scanPhotoSingle.
|
||||
4. Ma c'è un altro meccanismo importante: gli orfani
|
||||
Dopo scanPhotoSingle trovi:
|
||||
idsSet.delete(f.id);
|
||||
Questo significa:
|
||||
"Ho trovato fisicamente questo file durante la scansione, quindi il suo ID non è un orfano."
|
||||
Alla fine:
|
||||
for (const orphanId of idsSet) {
|
||||
log(`🔴 Orfano: ${orphanId}`);
|
||||
await deleteThumbsById(orphanId);
|
||||
await deleteFromDB(orphanId, user);
|
||||
}
|
||||
|
||||
DB all'inizio
|
||||
│
|
||||
├── A
|
||||
├── B
|
||||
└── C
|
||||
↓
|
||||
scansione filesystem
|
||||
↓
|
||||
trovo A → idsSet.delete(A)
|
||||
trovo C → idsSet.delete(C)
|
||||
↓
|
||||
rimane B
|
||||
↓
|
||||
B non esiste più sul filesystem
|
||||
↓
|
||||
B = ORFANO
|
||||
↓
|
||||
cancella thumbnail + DB
|
||||
|
||||
|
||||
Una foto viene presa in considerazione se:
|
||||
si trova dentro una cartella sotto original;
|
||||
è un file, non una directory;
|
||||
la sua estensione è presente in SUPPORTED_EXTS.
|
||||
Poi viene generato un ID:
|
||||
sha256(`${userName}/${cartella}/${fileRelPath}`)
|
||||
e il file viene passato a:
|
||||
scanPhotoSingle(...)
|
||||
|
||||
|
||||
La decisione è tutta qui
|
||||
Quando trova un file, fa:
|
||||
let prev = await db('photos')
|
||||
.select('id', 'size_bytes', 'mtimeMs', 'fast_hash', 'path')
|
||||
.where({ id })
|
||||
.first();
|
||||
Quindi cerca nel DB una foto con lo stesso ID.
|
||||
L'ID, come abbiamo visto prima, è:
|
||||
sha256(`${userName}/${cartella}/${fileRelPath}`)
|
||||
Quindi normalmente identifica quel preciso percorso del file.
|
||||
Poi calcola:
|
||||
const fastHash = sha256(`${st.size}-${st.mtimeMs}`);
|
||||
Quindi il contenuto non viene hashato: il "fast hash" dipende solo da:
|
||||
dimensione file + data modifica
|
||||
Le 3 situazioni
|
||||
1. Foto nuova
|
||||
Se:
|
||||
if (!prev)
|
||||
cioè non esiste nel DB un record con quell'ID, fa:
|
||||
const meta = await processFile(...)
|
||||
Questa è la parte che legge/elabora veramente la foto.
|
||||
Poi la inserisce nel DB:
|
||||
await db('photos').insert(...)
|
||||
e soprattutto:
|
||||
newFiles.push(meta);
|
||||
Quindi una foto nuova viene processata e considerata newFile.
|
||||
2. Foto già presente e invariata
|
||||
Questa è la situazione che probabilmente ti interessava:
|
||||
if (prev.fast_hash === fastHash) {
|
||||
In questo caso NON chiama processFile().
|
||||
Fa solamente:
|
||||
await db('photos')
|
||||
.where({ id })
|
||||
.update({
|
||||
path: f.path,
|
||||
size_bytes: st.size,
|
||||
mtimeMs: st.mtimeMs,
|
||||
fast_hash: fastHash,
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return null;
|
||||
Quindi:
|
||||
Se l'ID esiste e size + mtimeMs sono uguali, la foto viene saltata.
|
||||
Non viene riletta, non vengono rigenerate thumbnail, non viene ricalcolato GPS/location, ecc.
|
||||
Questo è il vero "skip".
|
||||
3. Foto modificata
|
||||
Se esiste prev, ma:
|
||||
prev.fast_hash !== fastHash
|
||||
allora:
|
||||
log(`${prefix} 🟠 Modificato: ${fileName}`);
|
||||
e viene nuovamente chiamato:
|
||||
const meta = await processFile(...)
|
||||
Quindi la foto viene riprocessata completamente.
|
||||
C'è però un caso speciale: path cambiato
|
||||
Prima del controllo fast_hash c'è:
|
||||
if (prev && prev.path !== f.path) {
|
||||
log(`${prefix} 🟢 Nuovo/Modificato (path changed): ${fileName}`);
|
||||
prev = null;
|
||||
}
|
||||
Questo significa:
|
||||
se trovo lo stesso id ma il path nel DB è diverso da quello attuale, tratto il file come nuovo.
|
||||
E quindi finisce qui:
|
||||
if (!prev) {
|
||||
...
|
||||
await processFile(...)
|
||||
...
|
||||
await db('photos').insert(...)
|
||||
}
|
||||
Ma qui vedo una possibile anomalia
|
||||
Se prev esiste già nel DB e fai:
|
||||
prev = null;
|
||||
poi fai:
|
||||
await db('photos').insert({
|
||||
id: meta.id,
|
||||
...
|
||||
});
|
||||
Se id è una chiave unica, potresti avere un errore di duplicate key invece di un vero aggiornamento.
|
||||
Però per capire se questo caso è realmente possibile bisogna vedere come viene costruito f.path e come viene gestito il DB.
|
||||
|
||||
|
||||
|
||||
TROVO FILE
|
||||
│
|
||||
▼
|
||||
estensione supportata?
|
||||
│ │
|
||||
NO SI
|
||||
│ │
|
||||
SKIP ▼
|
||||
calcolo ID
|
||||
│
|
||||
▼
|
||||
cerco ID nel DB
|
||||
│
|
||||
┌────────────┴────────────┐
|
||||
│ │
|
||||
non esiste esiste
|
||||
│ │
|
||||
▼ ▼
|
||||
processFile() path diverso?
|
||||
│ │ │
|
||||
│ SI NO
|
||||
│ │ │
|
||||
│ ▼ ▼
|
||||
│ prev=null confronto
|
||||
│ fastHash
|
||||
│ │ │
|
||||
│ UGUALE DIVERSO
|
||||
│ │ │
|
||||
│ ▼ ▼
|
||||
│ SKIP processFile()
|
||||
│ │
|
||||
└──────────────────────────────────────┘
|
||||
116
api_v1/scanner/scanActivitiesUser.js
Normal file
116
api_v1/scanner/scanActivitiesUser.js
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
// api_v1/scanner/scanActivitiesUser.js
|
||||
const path = require('path');
|
||||
const fsp = require('fs/promises');
|
||||
|
||||
const scanActivity = require('./scanActivity');
|
||||
const { log } = require('./logger');
|
||||
const { WEB_ROOT, SUPPORTED_ACTIVITY_EXTS } = require('../config');
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// PRIMA PASSATA: conta TUTTI i file reali ricorsivamente
|
||||
// ---------------------------------------------------------
|
||||
async function countActivityFilesUser(rootDir) {
|
||||
let count = 0;
|
||||
|
||||
async function walk(dir) {
|
||||
let entries = [];
|
||||
|
||||
try {
|
||||
entries = await fsp.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const e of entries) {
|
||||
const abs = path.join(dir, e.name);
|
||||
|
||||
if (e.isDirectory()) {
|
||||
await walk(abs);
|
||||
} else {
|
||||
const ext = path.extname(e.name).toLowerCase();
|
||||
|
||||
if (SUPPORTED_ACTIVITY_EXTS.has(ext)) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(rootDir);
|
||||
return count;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// SECONDA PASSATA: scansiona SOLO le cartelle vere
|
||||
// ---------------------------------------------------------
|
||||
async function scanActivitiesUser(userName, db) {
|
||||
log(`🔵 Inizio scan ATTIVITÀ per user=${userName}`);
|
||||
|
||||
const activitiesRoot = path.resolve(__dirname, '..', '..', WEB_ROOT, 'activities');
|
||||
const userDir = path.join(activitiesRoot, userName, 'original');
|
||||
|
||||
let entries = [];
|
||||
|
||||
try {
|
||||
entries = await fsp.readdir(userDir, { withFileTypes: true });
|
||||
} catch {
|
||||
log(`❌ Nessuna directory attività per utente ${userName}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Filtra SOLO cartelle vere dentro "original"
|
||||
const folders = entries
|
||||
.filter(e => e.isDirectory())
|
||||
.map(e => e.name);
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// RIMOZIONE CARTELLE CANCELLATE DAL FILESYSTEM
|
||||
// ---------------------------------------------------------
|
||||
const createDeleteFolderFunctions = require('./deleteFolder');
|
||||
const { deleteFolderForUser } = createDeleteFolderFunctions(db);
|
||||
|
||||
const dbFolders = await db('activities')
|
||||
.where({ user: userName })
|
||||
.distinct('cartella')
|
||||
.pluck('cartella');
|
||||
|
||||
for (const dbFolder of dbFolders) {
|
||||
if (!folders.includes(dbFolder)) {
|
||||
log(`🗑️ Cartella attività rimossa dal filesystem: ${dbFolder}`);
|
||||
await deleteFolderForUser(userName, dbFolder);
|
||||
}
|
||||
}
|
||||
|
||||
// PRIMA PASSATA: conta i file reali
|
||||
const TOTAL_FILES = await countActivityFilesUser(userDir);
|
||||
const CURRENT = { value: 0 };
|
||||
const start = Date.now();
|
||||
|
||||
log(`📊 Attività totali da scansionare: ${TOTAL_FILES}`);
|
||||
|
||||
const allNewActivities = [];
|
||||
|
||||
// SECONDA PASSATA: UNA SOLA SCANSIONE PER CARTELLA
|
||||
for (const cartella of folders) {
|
||||
log(`📁 Scan cartella attività: ${cartella}`);
|
||||
|
||||
const newActivities = await scanActivity(
|
||||
cartella,
|
||||
userName,
|
||||
db,
|
||||
CURRENT,
|
||||
TOTAL_FILES,
|
||||
start
|
||||
);
|
||||
|
||||
if (newActivities?.length) {
|
||||
allNewActivities.push(...newActivities);
|
||||
}
|
||||
}
|
||||
|
||||
log(`🟣 Scan ATTIVITÀ COMPLETATO per user=${userName}. Nuove attività: ${allNewActivities.length}`);
|
||||
|
||||
return allNewActivities;
|
||||
}
|
||||
|
||||
module.exports = scanActivitiesUser;
|
||||
52
api_v1/scanner/scanActivity.js
Normal file
52
api_v1/scanner/scanActivity.js
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
// api_v1/scanner/scanActivity.js
|
||||
const path = require('path');
|
||||
|
||||
const { log } = require('./logger');
|
||||
const scanActivityCartella = require('./scanActivityCartella');
|
||||
const createActivityOrphanCleanup = require('./activityOrphanCleanup');
|
||||
|
||||
const {
|
||||
WEB_ROOT
|
||||
} = require('../config');
|
||||
|
||||
// Il progresso globale viene letto dalla coda SQLite.
|
||||
// Mantiene la callback richiesta da scanActivityCartella.
|
||||
async function updateStatusFile() {}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// SCAN UNA SOLA CARTELLA DI ATTIVITÀ
|
||||
// ---------------------------------------------------------
|
||||
async function scanActivity(dir, userName, db, CURRENT, TOTAL_FILES, start, onProgress) {
|
||||
const newActivities = [];
|
||||
|
||||
const { deleteFromDB } = createActivityOrphanCleanup(db);
|
||||
|
||||
const activitiesRoot = path.resolve(__dirname, '..', '..', WEB_ROOT, 'activities');
|
||||
const userDir = path.join(activitiesRoot, userName, 'original');
|
||||
|
||||
const cartella = dir;
|
||||
const absCartella = path.join(userDir, cartella);
|
||||
|
||||
log(`📁 [SCAN ACTIVITY CARTELLA] ${cartella}`);
|
||||
|
||||
await scanActivityCartella(
|
||||
db,
|
||||
userName,
|
||||
cartella,
|
||||
absCartella,
|
||||
newActivities,
|
||||
updateStatusFile,
|
||||
CURRENT,
|
||||
TOTAL_FILES,
|
||||
start,
|
||||
deleteFromDB,
|
||||
onProgress
|
||||
);
|
||||
|
||||
// Parsing e sincronizzazione remota sono ora responsabilita
|
||||
// dell'ActivityIngestDaemon.
|
||||
|
||||
return newActivities;
|
||||
}
|
||||
|
||||
module.exports = scanActivity;
|
||||
84
api_v1/scanner/scanActivityCartella.js
Normal file
84
api_v1/scanner/scanActivityCartella.js
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
// api_v1/scanner/scanActivityCartella.js
|
||||
const scanActivityFiles = require('./scanActivityFiles');
|
||||
const {
|
||||
enqueueActivityIngestJobs,
|
||||
} = require('./activityIngestQueue');
|
||||
const { sha256 } = require('./utils');
|
||||
const { log } = require('./logger');
|
||||
|
||||
const BATCH_SIZE = 100;
|
||||
|
||||
async function scanActivityCartella(
|
||||
db,
|
||||
user,
|
||||
cartella,
|
||||
absCartella,
|
||||
newActivities,
|
||||
updateStatusFile,
|
||||
CURRENT,
|
||||
TOTAL_FILES,
|
||||
start,
|
||||
deleteFromDB,
|
||||
onProgress
|
||||
) {
|
||||
log(`📁 [SCAN ACTIVITY CARTELLA] ${cartella}`);
|
||||
|
||||
// Se la lettura del filesystem fallisce, lo scan deve fallire:
|
||||
// non dobbiamo interpretare tutti i record come orfani.
|
||||
const rows = await db('activities')
|
||||
.where({ user, cartella })
|
||||
.select('id');
|
||||
|
||||
const idsSet = new Set(rows.map(r => r.id));
|
||||
const files = [];
|
||||
|
||||
// Prima scopriamo tutti i file. Nessun orfano viene cancellato
|
||||
// se la scoperta o l'accodamento falliscono.
|
||||
for await (const f of scanActivityFiles(user, cartella, absCartella, db)) {
|
||||
files.push(f);
|
||||
}
|
||||
|
||||
for (let offset = 0; offset < files.length; offset += BATCH_SIZE) {
|
||||
const batch = files.slice(offset, offset + BATCH_SIZE);
|
||||
|
||||
const jobs = batch.map(f => ({
|
||||
user,
|
||||
cartella,
|
||||
relPath: f.relPath,
|
||||
mtimeMs: f.stat.mtimeMs,
|
||||
sizeBytes: f.stat.size,
|
||||
}));
|
||||
|
||||
// Una transazione per batch, anziché una per file.
|
||||
const results = await enqueueActivityIngestJobs(db, jobs);
|
||||
|
||||
for (let i = 0; i < batch.length; i++) {
|
||||
const f = batch[i];
|
||||
const result = results[i];
|
||||
|
||||
CURRENT.value++;
|
||||
idsSet.delete(sha256(`${user}/${cartella}/${f.relPath}`));
|
||||
|
||||
log(
|
||||
`📥 [ACTIVITY QUEUE] ${f.relPath} ` +
|
||||
`status=${result.status}` +
|
||||
`${result.inserted ? ' inserted' : ''}` +
|
||||
`${result.updated ? ' updated' : ''}` +
|
||||
`${result.unchanged ? ' unchanged' : ''}`
|
||||
);
|
||||
}
|
||||
|
||||
// Aggiorna l'interfaccia una volta per batch, non per file.
|
||||
await updateStatusFile(CURRENT, TOTAL_FILES, start);
|
||||
if (onProgress) await onProgress();
|
||||
}
|
||||
|
||||
// Questa sezione viene raggiunta soltanto se tutti i batch
|
||||
// sono stati accodati correttamente.
|
||||
for (const orphanId of idsSet) {
|
||||
log(`🔴 [ORPHAN ACTIVITY] ${orphanId}`);
|
||||
await deleteFromDB(orphanId, user);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = scanActivityCartella;
|
||||
45
api_v1/scanner/scanActivityFiles.js
Normal file
45
api_v1/scanner/scanActivityFiles.js
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// api_v1/scanner/scanActivityFiles.js
|
||||
const fsp = require('fs/promises');
|
||||
const path = require('path');
|
||||
|
||||
const SUPPORTED_ACTIVITY_EXTS = new Set(['.fit', '.gpx', '.tcx']);
|
||||
|
||||
async function* scanActivityFiles(user, cartella, absCartella, db) {
|
||||
async function* walk(dir) {
|
||||
// Gli errori non vengono nascosti: una scansione incompleta
|
||||
// non deve causare la cancellazione delle attività esistenti.
|
||||
const entries = await fsp.readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const absPath = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
yield* walk(absPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entry.isFile()) continue;
|
||||
|
||||
const ext = path.extname(entry.name).toLowerCase();
|
||||
if (!SUPPORTED_ACTIVITY_EXTS.has(ext)) continue;
|
||||
|
||||
const stat = await fsp.stat(absPath);
|
||||
const relPath = path.relative(absCartella, absPath)
|
||||
.split(path.sep).join('/');
|
||||
|
||||
yield {
|
||||
id: `${cartella}/${relPath}`,
|
||||
file_name: entry.name,
|
||||
relPath,
|
||||
absPath,
|
||||
ext,
|
||||
stat,
|
||||
path: `/activities/${user}/original/${cartella}/${relPath}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
yield* walk(absCartella);
|
||||
}
|
||||
|
||||
module.exports = scanActivityFiles;
|
||||
367
api_v1/scanner/scanActivitySingle.js
Normal file
367
api_v1/scanner/scanActivitySingle.js
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
// api_v1/scanner/scanActivitySingle.js
|
||||
const processActivityFile = require('./processActivityFile'); // parser FIT/GPX/TCX
|
||||
const {
|
||||
enqueueGeocodeJob,
|
||||
} = require('./geocodeQueue');
|
||||
const { sha256 } = require('./utils');
|
||||
const { log } = require('./logger');
|
||||
const {
|
||||
withSqliteBusyRetry,
|
||||
} = require('../../db/sqliteRetry');
|
||||
|
||||
// Se vuoi forzare il ricalcolo anche di file invariati:
|
||||
// ACTIVITY_FORCE_REPARSE=1 node ...
|
||||
const FORCE_REPARSE = process.env.ACTIVITY_FORCE_REPARSE === '1';
|
||||
|
||||
// Normalizza location come per le foto
|
||||
function normCountryCode(value) {
|
||||
if (!value) return undefined;
|
||||
const s = String(value).trim().toUpperCase();
|
||||
return s.length ? s : undefined;
|
||||
}
|
||||
|
||||
function normalizeLocation(location) {
|
||||
if (!location) return null;
|
||||
|
||||
const countryCode = normCountryCode(
|
||||
location.country_code ||
|
||||
location.countryCode ||
|
||||
location.isoCountryCode
|
||||
);
|
||||
|
||||
const countyCode =
|
||||
location.county_code ||
|
||||
location.countyCode ||
|
||||
undefined;
|
||||
|
||||
return {
|
||||
...location,
|
||||
country: location.country || undefined,
|
||||
country_code: countryCode,
|
||||
county: location.county || undefined,
|
||||
county_code: countyCode,
|
||||
region: location.region || undefined,
|
||||
city: location.city || undefined,
|
||||
postcode: location.postcode || undefined,
|
||||
address: location.address || undefined,
|
||||
timezone: location.timezone || undefined,
|
||||
time: location.time || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function serializeJson(value, fallback = null) {
|
||||
if (value == null) return fallback;
|
||||
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
async function scanActivitySingle(db, user, cartella, f, newActivities, prefix = '') {
|
||||
// const fileName = f.name;
|
||||
const fileName = f.file_name ?? f.name ?? f.relPath ?? 'unknown';
|
||||
const st = f.stat;
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// ID e fastHash PRIMA del parsing pesante
|
||||
// Deve essere identico a processActivityFile:
|
||||
// sha256(`${userName}/${cartella}/${fileRelPath}`)
|
||||
// ---------------------------------------------------------
|
||||
const id = sha256(`${user}/${cartella}/${f.relPath}`);
|
||||
const fastHash = sha256(`${st.size}-${st.mtimeMs}`);
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// HARD DELETED → non reimportare attività cancellate
|
||||
// ---------------------------------------------------------
|
||||
const hardDeleted = await db('deleted_hard_activities')
|
||||
.where({ id })
|
||||
.first();
|
||||
|
||||
if (hardDeleted) {
|
||||
log(`${prefix} ♻️ Activity restored: ${fileName}`);
|
||||
|
||||
await withSqliteBusyRetry(
|
||||
'activity hard-delete restore',
|
||||
() => db('deleted_hard_activities')
|
||||
.where({ id })
|
||||
.del()
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Recupero precedente usando id deterministico
|
||||
// ---------------------------------------------------------
|
||||
let prev = await db('activities')
|
||||
.select('id', 'size_bytes', 'last_modified_millis', 'fast_hash', 'path', 'deleted_at')
|
||||
.where({ id })
|
||||
.first();
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// PATH CAMBIATO = tratta come nuovo/modificato
|
||||
// ---------------------------------------------------------
|
||||
if (prev && prev.path !== f.path) {
|
||||
log(`${prefix} 🟠 Percorso attività cambiato: ${fileName}`);
|
||||
// Manteniamo prev: l'ID esiste già e il ramo di aggiornamento
|
||||
// aggiornerà path senza tentare un INSERT duplicato.
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// IDENTICO → SKIP IMMEDIATO, senza parsing FIT/GPX/TCX
|
||||
// ---------------------------------------------------------
|
||||
if (prev && prev.fast_hash === fastHash && !FORCE_REPARSE) {
|
||||
log(`${prefix} ⏭️ Activity invariata, skip parser: ${fileName}`);
|
||||
|
||||
// Aggiorno solo dati leggeri. Non tocco date_modified_millis,
|
||||
// così non genero changes inutili nel progressive sync.
|
||||
await withSqliteBusyRetry(
|
||||
'activity unchanged update',
|
||||
() => db('activities')
|
||||
.where({ id })
|
||||
.update({
|
||||
path: f.path,
|
||||
size_bytes: st.size,
|
||||
last_modified_millis: st.mtimeMs,
|
||||
last_scan_millis: Date.now(),
|
||||
fast_hash: fastHash,
|
||||
deleted_at: null,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Solo qui facciamo parsing completo FIT/GPX/TCX
|
||||
// ---------------------------------------------------------
|
||||
const meta = await processActivityFile(
|
||||
user,
|
||||
cartella,
|
||||
f.relPath,
|
||||
f.absPath,
|
||||
f.ext,
|
||||
st
|
||||
);
|
||||
|
||||
meta.path = f.path;
|
||||
meta.location = normalizeLocation(meta.location);
|
||||
|
||||
const trackPreviewJson = serializeJson(meta.track_preview || [], null);
|
||||
const tagsJson = serializeJson(meta.tags || [], null);
|
||||
const diagnosticsJson = serializeJson(meta.diagnostics || null, null);
|
||||
const locationJson = serializeJson(meta.location || null, null);
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// NUOVA ATTIVITÀ
|
||||
// ---------------------------------------------------------
|
||||
if (!prev) {
|
||||
log(`${prefix} 🟢 Nuova activity: ${fileName}`);
|
||||
|
||||
await withSqliteBusyRetry(
|
||||
'activity insert',
|
||||
() => db('activities').insert({
|
||||
id: meta.id,
|
||||
user,
|
||||
cartella,
|
||||
|
||||
file_name: meta.file_name,
|
||||
file_extension: meta.file_extension,
|
||||
format: meta.format,
|
||||
display_name: meta.display_name,
|
||||
route_name: meta.route_name,
|
||||
title: meta.title,
|
||||
description: meta.description,
|
||||
notes: meta.notes,
|
||||
sport: meta.sport,
|
||||
activity_type: meta.activity_type,
|
||||
device_manufacturer: meta.device_manufacturer,
|
||||
device_model: meta.device_model,
|
||||
source_app: meta.source_app,
|
||||
|
||||
start_time_millis: meta.start_time_millis,
|
||||
end_time_millis: meta.end_time_millis,
|
||||
duration_millis: meta.duration_millis,
|
||||
|
||||
distance_meters: meta.distance_meters,
|
||||
elevation_gain_meters: meta.elevation_gain_meters,
|
||||
elevation_loss_meters: meta.elevation_loss_meters,
|
||||
|
||||
min_elevation_meters: meta.min_elevation_meters,
|
||||
max_elevation_meters: meta.max_elevation_meters,
|
||||
|
||||
file_distance_meters: meta.file_distance_meters,
|
||||
file_elevation_gain_meters: meta.file_elevation_gain_meters,
|
||||
|
||||
computed_distance_meters: meta.computed_distance_meters,
|
||||
computed_elevation_gain_meters: meta.computed_elevation_gain_meters,
|
||||
|
||||
average_speed_mps: meta.average_speed_mps,
|
||||
max_speed_mps: meta.max_speed_mps,
|
||||
average_pace_sec_per_km: meta.average_pace_sec_per_km,
|
||||
|
||||
average_heart_rate: meta.average_heart_rate,
|
||||
max_heart_rate: meta.max_heart_rate,
|
||||
average_cadence: meta.average_cadence,
|
||||
max_cadence: meta.max_cadence,
|
||||
|
||||
calories: meta.calories,
|
||||
power_avg: meta.power_avg,
|
||||
power_max: meta.power_max,
|
||||
|
||||
point_count: meta.point_count,
|
||||
segment_count: meta.segment_count,
|
||||
lap_count: meta.lap_count,
|
||||
|
||||
min_latitude: meta.min_latitude,
|
||||
max_latitude: meta.max_latitude,
|
||||
min_longitude: meta.min_longitude,
|
||||
max_longitude: meta.max_longitude,
|
||||
center_latitude: meta.center_latitude,
|
||||
center_longitude: meta.center_longitude,
|
||||
|
||||
size_bytes: meta.size_bytes,
|
||||
last_modified_millis: meta.last_modified_millis,
|
||||
date_added_millis: meta.date_added_millis,
|
||||
date_modified_millis: meta.date_modified_millis,
|
||||
last_scan_millis: meta.last_scan_millis,
|
||||
|
||||
is_favorite: meta.is_favorite,
|
||||
is_hidden: meta.is_hidden,
|
||||
rating: meta.rating,
|
||||
|
||||
tags: tagsJson,
|
||||
track_preview: trackPreviewJson,
|
||||
|
||||
scan_status: meta.scan_status,
|
||||
scan_error: meta.scan_error,
|
||||
path: meta.path,
|
||||
|
||||
diagnostics: diagnosticsJson,
|
||||
location: locationJson,
|
||||
|
||||
fast_hash: fastHash,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
|
||||
await enqueueGeocodeJob({
|
||||
entityType: 'activity',
|
||||
entityId: meta.id,
|
||||
lat: meta.gps?.lat,
|
||||
lon: meta.gps?.lng,
|
||||
});
|
||||
|
||||
newActivities.push(meta);
|
||||
return meta;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// MODIFICATO oppure FORCE_REPARSE
|
||||
// ---------------------------------------------------------
|
||||
if (FORCE_REPARSE) {
|
||||
log(`${prefix} 🔁 Force reparse activity: ${fileName}`);
|
||||
} else {
|
||||
log(`${prefix} 🟠 Modificato activity: ${fileName}`);
|
||||
}
|
||||
|
||||
await withSqliteBusyRetry(
|
||||
'activity modified update',
|
||||
() => db('activities')
|
||||
.where({ id })
|
||||
.update({
|
||||
file_name: meta.file_name,
|
||||
file_extension: meta.file_extension,
|
||||
format: meta.format,
|
||||
display_name: meta.display_name,
|
||||
route_name: meta.route_name,
|
||||
title: meta.title,
|
||||
description: meta.description,
|
||||
notes: meta.notes,
|
||||
sport: meta.sport,
|
||||
activity_type: meta.activity_type,
|
||||
device_manufacturer: meta.device_manufacturer,
|
||||
device_model: meta.device_model,
|
||||
source_app: meta.source_app,
|
||||
|
||||
start_time_millis: meta.start_time_millis,
|
||||
end_time_millis: meta.end_time_millis,
|
||||
duration_millis: meta.duration_millis,
|
||||
|
||||
distance_meters: meta.distance_meters,
|
||||
elevation_gain_meters: meta.elevation_gain_meters,
|
||||
elevation_loss_meters: meta.elevation_loss_meters,
|
||||
|
||||
min_elevation_meters: meta.min_elevation_meters,
|
||||
max_elevation_meters: meta.max_elevation_meters,
|
||||
|
||||
file_distance_meters: meta.file_distance_meters,
|
||||
file_elevation_gain_meters: meta.file_elevation_gain_meters,
|
||||
|
||||
computed_distance_meters: meta.computed_distance_meters,
|
||||
computed_elevation_gain_meters: meta.computed_elevation_gain_meters,
|
||||
|
||||
average_speed_mps: meta.average_speed_mps,
|
||||
max_speed_mps: meta.max_speed_mps,
|
||||
average_pace_sec_per_km: meta.average_pace_sec_per_km,
|
||||
|
||||
average_heart_rate: meta.average_heart_rate,
|
||||
max_heart_rate: meta.max_heart_rate,
|
||||
average_cadence: meta.average_cadence,
|
||||
max_cadence: meta.max_cadence,
|
||||
|
||||
calories: meta.calories,
|
||||
power_avg: meta.power_avg,
|
||||
power_max: meta.power_max,
|
||||
|
||||
point_count: meta.point_count,
|
||||
segment_count: meta.segment_count,
|
||||
lap_count: meta.lap_count,
|
||||
|
||||
min_latitude: meta.min_latitude,
|
||||
max_latitude: meta.max_latitude,
|
||||
min_longitude: meta.min_longitude,
|
||||
max_longitude: meta.max_longitude,
|
||||
center_latitude: meta.center_latitude,
|
||||
center_longitude: meta.center_longitude,
|
||||
|
||||
size_bytes: meta.size_bytes,
|
||||
last_modified_millis: meta.last_modified_millis,
|
||||
date_added_millis: meta.date_added_millis,
|
||||
date_modified_millis: meta.date_modified_millis,
|
||||
last_scan_millis: meta.last_scan_millis,
|
||||
|
||||
is_favorite: meta.is_favorite,
|
||||
is_hidden: meta.is_hidden,
|
||||
rating: meta.rating,
|
||||
|
||||
tags: tagsJson,
|
||||
track_preview: trackPreviewJson,
|
||||
|
||||
scan_status: meta.scan_status,
|
||||
scan_error: meta.scan_error,
|
||||
path: meta.path,
|
||||
|
||||
diagnostics: diagnosticsJson,
|
||||
location: locationJson,
|
||||
|
||||
fast_hash: fastHash,
|
||||
deleted_at: null,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
|
||||
await enqueueGeocodeJob({
|
||||
entityType: 'activity',
|
||||
entityId: meta.id,
|
||||
lat: meta.gps?.lat,
|
||||
lon: meta.gps?.lng,
|
||||
});
|
||||
|
||||
newActivities.push(meta);
|
||||
return meta;
|
||||
}
|
||||
|
||||
module.exports = scanActivitySingle;
|
||||
211
api_v1/scanner/scanAuto.js
Normal file
211
api_v1/scanner/scanAuto.js
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
const path = require('path');
|
||||
const fsp = require('fs/promises');
|
||||
const scanCartella = require('./scanCartella');
|
||||
const processFile = require('./processFile');
|
||||
const { sha256 } = require('./utils');
|
||||
const createCleanupFunctions = require('./orphanCleanup');
|
||||
const { WEB_ROOT } = require('../config');
|
||||
const { log } = require('./logger');
|
||||
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// NORMALIZZAZIONE PATH (toglie /app/...)
|
||||
// ---------------------------------------------------------
|
||||
function normalizeRelPath(user, dirPath, fileName) {
|
||||
if (!dirPath) return "";
|
||||
|
||||
let rel = dirPath.replace(/\\/g, "/");
|
||||
|
||||
// Rimuove tutto fino a /photos/
|
||||
rel = rel.replace(/^.*\/photos\//, "");
|
||||
|
||||
// Ora rel = "Fabio/original/2017Irlanda/.../"
|
||||
const prefix = `${user}/original/`;
|
||||
|
||||
if (rel.startsWith(prefix)) {
|
||||
rel = rel.slice(prefix.length);
|
||||
}
|
||||
|
||||
if (rel.startsWith("/")) rel = rel.slice(1);
|
||||
|
||||
// Aggiunge il file
|
||||
if (fileName) {
|
||||
rel = `${rel}/${fileName}`.replace(/\/+/g, "/");
|
||||
}
|
||||
|
||||
return rel; // es: "2017Irlanda19-29ago/IMG_0120.JPG"
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// CALCOLO ID IDENTICO AL VECCHIO SCAN
|
||||
// ---------------------------------------------------------
|
||||
function computeId(user, relPath) {
|
||||
const parts = relPath.split('/').filter(Boolean);
|
||||
const cartella = parts[0];
|
||||
const fileRelPath = parts.slice(1).join('/');
|
||||
const id = sha256(`${user}/${cartella}/${fileRelPath}`);
|
||||
return { id, cartella, fileRelPath };
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 1) ADD FILE
|
||||
// ---------------------------------------------------------
|
||||
async function handleAddFile(user, dirPath, fileName, db) {
|
||||
const relPath = normalizeRelPath(user, dirPath, fileName);
|
||||
|
||||
log(`🟦 [ADD] user=${user} file=${relPath}`);
|
||||
|
||||
// 🔥 Ricostruzione identica al vecchio scanPhoto
|
||||
const parts = relPath.split('/');
|
||||
const cartella = parts[0]; // es: "2017Irlanda19-29ago"
|
||||
const fileRelPath = parts.slice(1).join('/'); // es: "IMG_0116.JPG"
|
||||
|
||||
const photosRoot = path.resolve(__dirname, '..', '..', WEB_ROOT, 'photos');
|
||||
const absPath = path.join(photosRoot, user, 'original', cartella, fileRelPath);
|
||||
|
||||
const ext = path.extname(fileName).toLowerCase();
|
||||
const st = await fsp.stat(absPath);
|
||||
|
||||
// 🔥 FIX: processFile deve ricevere fileRelPath completo di "original/"
|
||||
const meta = await processFile(
|
||||
user,
|
||||
cartella,
|
||||
path.posix.join('original', cartella, fileRelPath),
|
||||
absPath,
|
||||
ext,
|
||||
st
|
||||
);
|
||||
|
||||
// Inserimento identico al vecchio scanPhoto
|
||||
const row = {
|
||||
id: meta.id,
|
||||
user,
|
||||
cartella,
|
||||
name: meta.name,
|
||||
path: meta.path,
|
||||
thub1: meta.thub1,
|
||||
thub2: meta.thub2,
|
||||
cast: meta.cast,
|
||||
mime_type: meta.mime_type,
|
||||
width: meta.width,
|
||||
height: meta.height,
|
||||
rotation: meta.rotation,
|
||||
size_bytes: meta.size_bytes,
|
||||
mtimeMs: meta.mtimeMs,
|
||||
duration_ms: meta.duration_ms ?? null,
|
||||
taken_at: meta.taken_at ?? null,
|
||||
data: meta.data ?? null,
|
||||
lat: meta.gps?.lat ?? null,
|
||||
lon: meta.gps?.lng ?? null,
|
||||
alt: meta.gps?.alt ?? null,
|
||||
location: meta.location ? JSON.stringify(meta.location) : null
|
||||
};
|
||||
|
||||
await db('photos')
|
||||
.insert(row)
|
||||
.onConflict('id')
|
||||
.merge();
|
||||
|
||||
log(`🟢 [ADD] Inserito/aggiornato id=${meta.id}`);
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 2) DELETE FILE
|
||||
// ---------------------------------------------------------
|
||||
async function handleDeleteFile(user, dirPath, fileName, db) {
|
||||
const relPath = normalizeRelPath(user, dirPath, fileName);
|
||||
|
||||
log(`🟥 [DEL] user=${user} file=${relPath}`);
|
||||
|
||||
const { id } = computeId(user, relPath);
|
||||
|
||||
const { deleteThumbsById, deleteFromDB } = createCleanupFunctions(db);
|
||||
|
||||
await deleteThumbsById(id);
|
||||
await deleteFromDB(id, user);
|
||||
|
||||
log(`🔴 [DEL] File eliminato id=${id}`);
|
||||
|
||||
return { deleted: id };
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 3) ADD DIRECTORY
|
||||
// ---------------------------------------------------------
|
||||
async function handleAddDir(user, dir, db) {
|
||||
log(`🟦 [ADD_DIR] user=${user} dir=${dir}`);
|
||||
|
||||
const photosRoot = path.resolve(__dirname, '..', '..', WEB_ROOT, 'photos');
|
||||
const absDir = path.join(photosRoot, user, 'original', dir);
|
||||
|
||||
log(`📁 [ADD_DIR] absDir=${absDir}`);
|
||||
|
||||
let results = [];
|
||||
|
||||
for await (const f of scanCartella(user, dir, absDir, db)) {
|
||||
log(`📄 [ADD_DIR] File trovato: ${f.relPath}`);
|
||||
|
||||
const meta = await processFile(
|
||||
user,
|
||||
dir,
|
||||
f.relPath,
|
||||
f.absPath,
|
||||
f.ext,
|
||||
f.stat
|
||||
);
|
||||
|
||||
await db('photos').insert(meta).onConflict('id').merge();
|
||||
|
||||
log(`🟢 [ADD_DIR] Inserito/aggiornato id=${meta.id} name=${meta.name}`);
|
||||
|
||||
results.push(meta);
|
||||
}
|
||||
|
||||
log(`🟣 [ADD_DIR] Completato. Files=${results.length}`);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 4) DELETE DIRECTORY
|
||||
// ---------------------------------------------------------
|
||||
async function handleDeleteDir(user, dir, db) {
|
||||
log(`🟥 [DEL_DIR] user=${user} dir=${dir}`);
|
||||
|
||||
const rows = await db('photos')
|
||||
.where({ user, cartella: dir })
|
||||
.select('id');
|
||||
|
||||
const { deleteThumbsById, deleteFromDB } = createCleanupFunctions(db);
|
||||
|
||||
log(`📁 [DEL_DIR] Trovati ${rows.length} file da eliminare`);
|
||||
|
||||
for (const r of rows) {
|
||||
log(`🗑️ [DEL_DIR] Eliminazione id=${r.id}`);
|
||||
await deleteThumbsById(r.id);
|
||||
await deleteFromDB(r.id, user);
|
||||
}
|
||||
|
||||
log(`🔴 [DEL_DIR] Cartella eliminata. Totale file=${rows.length}`);
|
||||
|
||||
return { deleted: rows.length };
|
||||
}
|
||||
|
||||
|
||||
|
||||
module.exports = {
|
||||
handleAddFile,
|
||||
handleDeleteFile,
|
||||
handleAddDir,
|
||||
handleDeleteDir
|
||||
};
|
||||
67
api_v1/scanner/scanCartella.js
Normal file
67
api_v1/scanner/scanCartella.js
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
// api_v1/scanner/scanCartella.js
|
||||
const path = require('path');
|
||||
const fsp = require('fs/promises');
|
||||
const fs = require('fs');
|
||||
const { sha256 } = require('./utils');
|
||||
const { SUPPORTED_EXTS } = require('../config');
|
||||
const { log } = require('./logger');
|
||||
|
||||
/**
|
||||
* Scansiona ricorsivamente la cartella:
|
||||
* /photos/<user>/original/<cartella>/
|
||||
*
|
||||
* Restituisce (yield) i metadati dei file trovati.
|
||||
*/
|
||||
async function* scanCartella(userName, cartella, absCartella, db) {
|
||||
|
||||
async function* walk(currentAbs, relPath = '') {
|
||||
let entries = [];
|
||||
try {
|
||||
entries = await fsp.readdir(currentAbs, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const e of entries) {
|
||||
const absPath = path.join(currentAbs, e.name);
|
||||
|
||||
if (e.isDirectory()) {
|
||||
yield* walk(absPath, path.join(relPath, e.name));
|
||||
continue;
|
||||
}
|
||||
|
||||
const ext = path.extname(e.name).toLowerCase();
|
||||
if (!SUPPORTED_EXTS.has(ext)) continue;
|
||||
|
||||
const fileRelPath = relPath ? `${relPath}/${e.name}` : e.name;
|
||||
|
||||
// ID deterministico basato sul percorso
|
||||
const id = sha256(`${userName}/${cartella}/${fileRelPath}`);
|
||||
|
||||
let st;
|
||||
try {
|
||||
st = await fsp.stat(absPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Log per-file disabilitato: rallenta significativamente lo scan.
|
||||
|
||||
yield {
|
||||
id,
|
||||
user: userName,
|
||||
cartella,
|
||||
name: e.name,
|
||||
relPath: fileRelPath,
|
||||
absPath,
|
||||
ext,
|
||||
stat: st,
|
||||
path: `/photos/${userName}/original/${cartella}/${fileRelPath}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
yield* walk(absCartella);
|
||||
}
|
||||
|
||||
module.exports = scanCartella;
|
||||
|
||||
235
api_v1/scanner/scanFile.js
Normal file
235
api_v1/scanner/scanFile.js
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
// api_v1/scanner/scanFile.js
|
||||
const path = require('path');
|
||||
const fsp = require('fs/promises');
|
||||
const { sha256 } = require('./utils');
|
||||
const { SUPPORTED_EXTS } = require('../config');
|
||||
const { log } = require('./logger');
|
||||
|
||||
/**
|
||||
* scanFile: genera informazioni su UN singolo file
|
||||
* Restituisce lo stesso formato di scanCartella, ma senza recursion.
|
||||
*
|
||||
* Parametri:
|
||||
* - userName
|
||||
* - cartella
|
||||
* - absFile (percorso assoluto del file)
|
||||
* - db (opzionale)
|
||||
*/
|
||||
async function scanFileEntry(userName, cartella, absFile, db) {
|
||||
const ext = path.extname(absFile).toLowerCase();
|
||||
if (!SUPPORTED_EXTS.has(ext)) {
|
||||
log(`⛔ Estensione non supportata: ${ext}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const name = path.basename(absFile);
|
||||
const relPath = name; // singolo file → niente struttura ricorsiva
|
||||
|
||||
let st;
|
||||
try {
|
||||
st = await fsp.stat(absFile);
|
||||
} catch {
|
||||
log(`⛔ Impossibile leggere stat per ${absFile}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = sha256(`${userName}/${cartella}/${relPath}`);
|
||||
|
||||
log(`📄 scanFile → user=${userName} cartella=${cartella} file=${name} id=${id}`);
|
||||
|
||||
return {
|
||||
id,
|
||||
user: userName,
|
||||
cartella,
|
||||
name,
|
||||
relPath,
|
||||
absPath: absFile,
|
||||
ext,
|
||||
stat: st,
|
||||
path: `/photos/${userName}/original/${cartella}/${relPath}`
|
||||
};
|
||||
}
|
||||
|
||||
async function scanFile(userName, cartella, absFile, db) {
|
||||
const f = await scanFile(user, cart, absFile);
|
||||
const fileName = f.name;
|
||||
const id = f.id;
|
||||
const st = f.stat;
|
||||
|
||||
|
||||
let prev = await db("photos")
|
||||
.select("id", "size_bytes", "mtimeMs", "_indexHash", "fast_hash", "path")
|
||||
.where({ id })
|
||||
.first();
|
||||
|
||||
if (prev && LOG_VERBOSE) {
|
||||
log(`${prefix} ⚪ Invariato: ${fileName}`);
|
||||
}
|
||||
|
||||
const fastHash = sha256(`${st.size}-${st.mtimeMs}`);
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// PATH CAMBIATO = NUOVO FILE
|
||||
// ---------------------------------------------------------
|
||||
if (prev && prev.path !== f.path) {
|
||||
log(`${prefix} 🟢 Nuovo/Modificato: ${fileName}`);
|
||||
prev = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// NUOVO FILE
|
||||
// ---------------------------------------------------------
|
||||
if (!prev) {
|
||||
log(`${prefix} 🟢 Nuovo/Modificato: ${fileName}`);
|
||||
|
||||
const meta = await processFile(
|
||||
user,
|
||||
cartella,
|
||||
f.relPath,
|
||||
f.absPath,
|
||||
f.ext,
|
||||
st
|
||||
);
|
||||
|
||||
meta.id = id;
|
||||
meta.path = f.path;
|
||||
|
||||
// INSERT CORRETTO (senza colonna gps)
|
||||
await db("photos").insert({
|
||||
id: meta.id,
|
||||
user,
|
||||
cartella,
|
||||
name: meta.name,
|
||||
path: meta.path,
|
||||
thub1: meta.thub1,
|
||||
thub2: meta.thub2,
|
||||
cast: meta.cast,
|
||||
mime_type: meta.mime_type,
|
||||
width: meta.width,
|
||||
height: meta.height,
|
||||
rotation: meta.rotation,
|
||||
size_bytes: meta.size_bytes,
|
||||
mtimeMs: meta.mtimeMs,
|
||||
duration_ms: meta.duration_ms,
|
||||
taken_at: meta.taken_at,
|
||||
data: meta.data,
|
||||
lat: meta.gps?.lat ?? null,
|
||||
lon: meta.gps?.lng ?? null,
|
||||
alt: meta.gps?.alt ?? null,
|
||||
location: meta.location ? JSON.stringify(meta.location) : null,
|
||||
_indexHash: meta._indexHash,
|
||||
fast_hash: fastHash
|
||||
});
|
||||
|
||||
await db('photo_changes').insert({
|
||||
photo_id: meta.id,
|
||||
user,
|
||||
change_type: 'added',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
|
||||
newFiles.push(meta);
|
||||
//log(`🟢 [PUSH newFiles] id=${meta.id} path=${meta.path}`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// FAST-SIZE-SKIP
|
||||
// ---------------------------------------------------------
|
||||
if (prev.size_bytes === st.size) {
|
||||
//log(`🔵 [FAST-SIZE-SKIP] id=${id}`);
|
||||
|
||||
await db("photos")
|
||||
.where({ id })
|
||||
.update({
|
||||
path: f.path,
|
||||
size_bytes: st.size,
|
||||
mtimeMs: st.mtimeMs,
|
||||
fast_hash: fastHash
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// FAST-HASH-SKIP
|
||||
// ---------------------------------------------------------
|
||||
if (prev.fast_hash === fastHash) {
|
||||
//log(`🔵 [FAST-HASH-SKIP] id=${id}`);
|
||||
|
||||
await db("photos")
|
||||
.where({ id })
|
||||
.update({
|
||||
path: f.path,
|
||||
size_bytes: st.size,
|
||||
mtimeMs: st.mtimeMs
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// MODIFICATO
|
||||
// ---------------------------------------------------------
|
||||
//log(`🟠 [FULL-SCAN] id=${id}`);
|
||||
log(`${prefix} 🟠 Nuovo/Modificato: ${fileName}`);
|
||||
const meta = await processFile(
|
||||
user,
|
||||
cartella,
|
||||
f.relPath,
|
||||
f.absPath,
|
||||
f.ext,
|
||||
st
|
||||
);
|
||||
|
||||
meta.id = id;
|
||||
meta.path = f.path;
|
||||
|
||||
await db("photos")
|
||||
.insert({
|
||||
id: meta.id,
|
||||
user,
|
||||
cartella,
|
||||
name: meta.name,
|
||||
path: meta.path,
|
||||
thub1: meta.thub1,
|
||||
thub2: meta.thub2,
|
||||
cast: meta.cast,
|
||||
mime_type: meta.mime_type,
|
||||
width: meta.width,
|
||||
height: meta.height,
|
||||
rotation: meta.rotation,
|
||||
size_bytes: meta.size_bytes,
|
||||
mtimeMs: meta.mtimeMs,
|
||||
duration_ms: meta.duration_ms,
|
||||
taken_at: meta.taken_at,
|
||||
data: meta.data,
|
||||
lat: meta.gps?.lat ?? null,
|
||||
lon: meta.gps?.lng ?? null,
|
||||
alt: meta.gps?.alt ?? null,
|
||||
location: meta.location ? JSON.stringify(meta.location) : null,
|
||||
_indexHash: meta._indexHash,
|
||||
fast_hash: fastHash
|
||||
})
|
||||
.onConflict("id")
|
||||
.merge();
|
||||
|
||||
await db('photo_changes').insert({
|
||||
photo_id: meta.id,
|
||||
user,
|
||||
change_type: 'updated',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
newFiles.push(meta);
|
||||
log(`${prefix} 🟠 Nuovo/Modificato al server ${fileName}`);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
module.exports = scanFile;
|
||||
53
api_v1/scanner/scanFile1.js
Normal file
53
api_v1/scanner/scanFile1.js
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
// api_v1/scanner/scanFile.js
|
||||
const path = require('path');
|
||||
const fsp = require('fs/promises');
|
||||
const { sha256 } = require('./utils');
|
||||
const { SUPPORTED_EXTS } = require('../config');
|
||||
const { log } = require('./logger');
|
||||
|
||||
/**
|
||||
* scanFile: genera informazioni su UN singolo file
|
||||
* Restituisce lo stesso formato di scanCartella, ma senza recursion.
|
||||
*
|
||||
* Parametri:
|
||||
* - userName
|
||||
* - cartella
|
||||
* - absFile (percorso assoluto del file)
|
||||
* - db (opzionale)
|
||||
*/
|
||||
async function scanFile(userName, cartella, absFile, db) {
|
||||
const ext = path.extname(absFile).toLowerCase();
|
||||
if (!SUPPORTED_EXTS.has(ext)) {
|
||||
log(`⛔ Estensione non supportata: ${ext}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const name = path.basename(absFile);
|
||||
const relPath = name; // singolo file → niente struttura ricorsiva
|
||||
|
||||
let st;
|
||||
try {
|
||||
st = await fsp.stat(absFile);
|
||||
} catch {
|
||||
log(`⛔ Impossibile leggere stat per ${absFile}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = sha256(`${userName}/${cartella}/${relPath}`);
|
||||
|
||||
log(`📄 scanFile → user=${userName} cartella=${cartella} file=${name} id=${id}`);
|
||||
|
||||
return {
|
||||
id,
|
||||
user: userName,
|
||||
cartella,
|
||||
name,
|
||||
relPath,
|
||||
absPath: absFile,
|
||||
ext,
|
||||
stat: st,
|
||||
path: `/photos/${userName}/original/${cartella}/${relPath}`
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = scanFile;
|
||||
55
api_v1/scanner/scanFileEntry.js
Normal file
55
api_v1/scanner/scanFileEntry.js
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
// api_v1/scanner/scanFileEntry.js
|
||||
const path = require('path');
|
||||
const fsp = require('fs/promises');
|
||||
const { sha256 } = require('./utils');
|
||||
const { SUPPORTED_EXTS } = require('../config');
|
||||
const { log } = require('./logger');
|
||||
|
||||
/**
|
||||
* scanFile: genera informazioni su UN singolo file
|
||||
* Restituisce lo stesso formato di scanCartella, ma senza recursion.
|
||||
*
|
||||
* Parametri:
|
||||
* - userName
|
||||
* - cartella
|
||||
* - absFile (percorso assoluto del file)
|
||||
* - db (opzionale)
|
||||
*/
|
||||
async function scanFile(userName, cartella, absFile, db) {
|
||||
const ext = path.extname(absFile).toLowerCase();
|
||||
if (!SUPPORTED_EXTS.has(ext)) {
|
||||
log(`⛔ Estensione non supportata: ${ext}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const name = path.basename(absFile);
|
||||
const relPath = name; // singolo file → niente struttura ricorsiva
|
||||
|
||||
// ID deterministico (funziona anche se il file non esiste)
|
||||
const id = sha256(`${userName}/${cartella}/${relPath}`);
|
||||
|
||||
let st = null;
|
||||
try {
|
||||
st = await fsp.stat(absFile);
|
||||
} catch {
|
||||
// File NON esiste → caso DEL
|
||||
log(`⛔ Impossibile leggere stat per ${absFile} (file mancante)`);
|
||||
// Continuiamo comunque: l'ID è valido e serve per DEL
|
||||
}
|
||||
|
||||
log(`📄 scanFile → user=${userName} cartella=${cartella} file=${name} id=${id}`);
|
||||
|
||||
return {
|
||||
id,
|
||||
user: userName,
|
||||
cartella,
|
||||
name,
|
||||
relPath,
|
||||
absPath: absFile,
|
||||
ext,
|
||||
stat: st, // può essere null
|
||||
path: `/photos/${userName}/original/${cartella}/${relPath}`
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = scanFile;
|
||||
76
api_v1/scanner/scanNewCartella.js
Normal file
76
api_v1/scanner/scanNewCartella.js
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// api_v1/scanner/scanNewCartella.js
|
||||
const path = require('path');
|
||||
const fsp = require('fs/promises');
|
||||
const { WEB_ROOT, SUPPORTED_EXTS } = require('../config');
|
||||
const scanPhoto = require('./scanPhoto');
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Conta ricorsivamente i file in /photos/<user>/original/<folder>/
|
||||
// ---------------------------------------------------------
|
||||
async function countFilesInUserFolder(userName, folderName) {
|
||||
const rootDir = path.join(WEB_ROOT, userName, "original", folderName);
|
||||
let count = 0;
|
||||
|
||||
async function walk(dir) {
|
||||
let entries = [];
|
||||
try {
|
||||
entries = await fsp.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const e of entries) {
|
||||
const abs = path.join(dir, e.name);
|
||||
|
||||
if (e.isDirectory()) {
|
||||
await walk(abs);
|
||||
} else {
|
||||
const ext = path.extname(e.name).toLowerCase();
|
||||
if (SUPPORTED_EXTS.has(ext)) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(rootDir);
|
||||
return count;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Crea thumbs/<folder> e scansiona la cartella come fa /scan
|
||||
// ---------------------------------------------------------
|
||||
async function scanNewCartella(userName, folderName, db) {
|
||||
// 1) crea la cartella root in thumbs
|
||||
const thumbsDir = path.join(WEB_ROOT, userName, "thumbs", folderName);
|
||||
await fsp.mkdir(thumbsDir, { recursive: true });
|
||||
|
||||
// 2) conta i file ricorsivamente
|
||||
const TOTAL_FILES = await countFilesInUserFolder(userName, folderName);
|
||||
|
||||
// 3) esegui lo scan della cartella
|
||||
const CURRENT = { value: 0 };
|
||||
const start = Date.now();
|
||||
|
||||
// ⭐ newFiles conterrà i meta delle foto nuove
|
||||
const newFiles = await scanPhoto(
|
||||
folderName,
|
||||
userName,
|
||||
db,
|
||||
CURRENT,
|
||||
TOTAL_FILES,
|
||||
start
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
folder: folderName,
|
||||
totalFiles: TOTAL_FILES,
|
||||
newFiles
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
scanNewCartella,
|
||||
countFilesInUserFolder
|
||||
};
|
||||
111
api_v1/scanner/scanPhoto.js
Normal file
111
api_v1/scanner/scanPhoto.js
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
// api_v1/scanner/scanPhoto.js
|
||||
const path = require('path');
|
||||
|
||||
const { log } = require('./logger');
|
||||
const scanPhotoCartella = require('./scanPhotoCartella');
|
||||
const postWithAuth = require('./postWithAuth');
|
||||
const createCleanupFunctions = require('./orphanCleanup');
|
||||
const writeJsonAtomic = require('./writeJsonAtomic');
|
||||
|
||||
const {
|
||||
WEB_ROOT,
|
||||
SEND_PHOTOS,
|
||||
BASE_URL
|
||||
} = require('../config');
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// FORMAT TIME
|
||||
// ---------------------------------------------------------
|
||||
function formatTime(ms) {
|
||||
const sec = Math.floor(ms / 1000);
|
||||
const h = String(Math.floor(sec / 3600)).padStart(2, '0');
|
||||
const m = String(Math.floor((sec % 3600) / 60)).padStart(2, '0');
|
||||
const s = String(sec % 60).padStart(2, '0');
|
||||
return `${h}:${m}:${s}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// UPDATE STATUS FILE
|
||||
// ---------------------------------------------------------
|
||||
async function updateStatusFile(CURRENT, TOTAL_FILES, start) {
|
||||
const now = Date.now();
|
||||
const elapsedMs = now - start;
|
||||
const avg = elapsedMs / Math.max(CURRENT.value, 1);
|
||||
const remainingMs = (TOTAL_FILES - CURRENT.value) * avg;
|
||||
|
||||
const percent = TOTAL_FILES > 0
|
||||
? Number(((CURRENT.value / TOTAL_FILES) * 100).toFixed(2))
|
||||
: 100;
|
||||
|
||||
const status = {
|
||||
current: CURRENT.value,
|
||||
total: TOTAL_FILES,
|
||||
percent,
|
||||
eta: formatTime(remainingMs),
|
||||
elapsed: formatTime(elapsedMs)
|
||||
};
|
||||
|
||||
const statusPath = path.resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'public/photos/scan_status.json'
|
||||
);
|
||||
|
||||
await writeJsonAtomic(statusPath, status);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// SCAN UNA SOLA CARTELLA
|
||||
// ---------------------------------------------------------
|
||||
async function scanPhoto(dir, userName, db, CURRENT, TOTAL_FILES, start, onProgress) {
|
||||
const newFiles = [];
|
||||
|
||||
const { deleteThumbsById, deleteFromDB } = createCleanupFunctions(db);
|
||||
|
||||
const photosRoot = path.resolve(__dirname, '..', '..', WEB_ROOT, 'photos');
|
||||
const userDir = path.join(photosRoot, userName, 'original');
|
||||
|
||||
const cartella = dir;
|
||||
const absCartella = path.join(userDir, cartella);
|
||||
|
||||
log(`📁 [SCAN CARTELLA] ${cartella}`);
|
||||
|
||||
await scanPhotoCartella(
|
||||
db,
|
||||
userName,
|
||||
cartella,
|
||||
absCartella,
|
||||
newFiles,
|
||||
updateStatusFile,
|
||||
CURRENT,
|
||||
TOTAL_FILES,
|
||||
start,
|
||||
deleteThumbsById,
|
||||
deleteFromDB,
|
||||
onProgress
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// INVIO AL SERVER REMOTO
|
||||
// ---------------------------------------------------------
|
||||
if (SEND_PHOTOS && BASE_URL && newFiles.length > 0) {
|
||||
log(`📤 [SEND START] newFiles=${newFiles.length}`);
|
||||
|
||||
for (const p of newFiles) {
|
||||
try {
|
||||
p.user = userName;
|
||||
await postWithAuth(`${BASE_URL}/photos`, p);
|
||||
log(`📥 [SENT TO SERVER] ${p.name}`);
|
||||
} catch (err) {
|
||||
log(`❌ [SERVER SENT ERROR] ${p.name} → ${err.message}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log(`⚠️ [NO SEND] newFiles=${newFiles.length}`);
|
||||
}
|
||||
|
||||
return newFiles;
|
||||
}
|
||||
|
||||
module.exports = scanPhoto;
|
||||
141
api_v1/scanner/scanPhotoCartella.js
Normal file
141
api_v1/scanner/scanPhotoCartella.js
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
// api_v1/scanner/scanPhotoCartella.js
|
||||
|
||||
const scanCartella = require('./scanCartella');
|
||||
const scanPhotoSingle = require('./scanPhotoSingle');
|
||||
const { log } = require('./logger');
|
||||
|
||||
async function scanPhotoCartella(
|
||||
db,
|
||||
user,
|
||||
cartella,
|
||||
absCartella,
|
||||
newFiles,
|
||||
updateStatusFile,
|
||||
CURRENT,
|
||||
TOTAL_FILES,
|
||||
start,
|
||||
deleteThumbsById,
|
||||
deleteFromDB,
|
||||
onProgress
|
||||
) {
|
||||
log(`📁 Scan cartella: ${cartella}`);
|
||||
let lastProgress = 0;
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// CARICA TUTTI I RECORD DELLA CARTELLA UNA SOLA VOLTA
|
||||
// ---------------------------------------------------------
|
||||
|
||||
const rows = await db('photos')
|
||||
.where({ user, cartella })
|
||||
.select(
|
||||
'id',
|
||||
'size_bytes',
|
||||
'mtimeMs',
|
||||
'fast_hash',
|
||||
'path'
|
||||
);
|
||||
|
||||
// Map: id -> record DB
|
||||
const photosMap = new Map(
|
||||
rows.map(row => [row.id, row])
|
||||
);
|
||||
|
||||
// Manteniamo il Set per individuare gli orfani
|
||||
const idsSet = new Set(
|
||||
rows.map(row => row.id)
|
||||
);
|
||||
|
||||
log(
|
||||
`📊 DB cartella=${cartella}: ${rows.length} record`
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// SCANSIONE FILESYSTEM
|
||||
// ---------------------------------------------------------
|
||||
|
||||
for await (
|
||||
const f of scanCartella(
|
||||
user,
|
||||
cartella,
|
||||
absCartella,
|
||||
db
|
||||
)
|
||||
) {
|
||||
// Aggiorna contatore
|
||||
CURRENT.value++;
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// PROGRESS THROTTLED
|
||||
//
|
||||
// Evita una scrittura su disco per ogni singolo file.
|
||||
// Aggiorniamo al massimo ogni 500 ms.
|
||||
// ---------------------------------------------------------
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
if (now - lastProgress >= 500) {
|
||||
lastProgress = now;
|
||||
|
||||
if (updateStatusFile) {
|
||||
await updateStatusFile(
|
||||
CURRENT,
|
||||
TOTAL_FILES,
|
||||
start
|
||||
);
|
||||
}
|
||||
|
||||
if (onProgress) {
|
||||
await onProgress();
|
||||
}
|
||||
}
|
||||
|
||||
// Recupera il record DB dalla Map
|
||||
const prev = photosMap.get(f.id) || null;
|
||||
|
||||
// Processa il file
|
||||
await scanPhotoSingle(
|
||||
db,
|
||||
user,
|
||||
cartella,
|
||||
f,
|
||||
newFiles,
|
||||
'',
|
||||
prev
|
||||
);
|
||||
|
||||
// Il file esiste realmente nel filesystem:
|
||||
// quindi non è un orfano.
|
||||
idsSet.delete(f.id);
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// ULTIMO AGGIORNAMENTO PROGRESS
|
||||
// ---------------------------------------------------------
|
||||
|
||||
if (updateStatusFile) {
|
||||
await updateStatusFile(
|
||||
CURRENT,
|
||||
TOTAL_FILES,
|
||||
start
|
||||
);
|
||||
}
|
||||
|
||||
if (onProgress) {
|
||||
await onProgress();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// GESTIONE ORFANI
|
||||
// ---------------------------------------------------------
|
||||
|
||||
|
||||
for (const orphanId of idsSet) {
|
||||
log(`🔴 Orfano: ${orphanId}`);
|
||||
|
||||
await deleteThumbsById(orphanId);
|
||||
await deleteFromDB(orphanId, user);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = scanPhotoCartella;
|
||||
423
api_v1/scanner/scanPhotoSingle.js
Normal file
423
api_v1/scanner/scanPhotoSingle.js
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
// api_v1/scanner/scanPhotoSingle.js
|
||||
|
||||
const path = require('path');
|
||||
const fsp = require('fs/promises');
|
||||
|
||||
const processFile = require('./processFile');
|
||||
const {
|
||||
enqueueGeocodeJob,
|
||||
} = require('./geocodeQueue');
|
||||
const { sha256 } = require('./utils');
|
||||
const { log } = require('./logger');
|
||||
const { WEB_ROOT } = require('../config');
|
||||
|
||||
function normCountryCode(value) {
|
||||
if (!value) return undefined;
|
||||
|
||||
const s = String(value).trim().toUpperCase();
|
||||
|
||||
return s.length ? s : undefined;
|
||||
}
|
||||
|
||||
function normalizeLocation(location) {
|
||||
if (!location) return null;
|
||||
|
||||
const countryCode = normCountryCode(
|
||||
location.country_code ||
|
||||
location.countryCode ||
|
||||
location.isoCountryCode
|
||||
);
|
||||
|
||||
const countyCode =
|
||||
location.county_code ||
|
||||
location.countyCode ||
|
||||
undefined;
|
||||
|
||||
return {
|
||||
...location,
|
||||
|
||||
// Paese
|
||||
country: location.country || undefined,
|
||||
country_code: countryCode,
|
||||
|
||||
// County / contea
|
||||
county: location.county || undefined,
|
||||
county_code: countyCode,
|
||||
|
||||
// Campi già esistenti
|
||||
region: location.region || undefined,
|
||||
city: location.city || undefined,
|
||||
postcode: location.postcode || undefined,
|
||||
address: location.address || undefined,
|
||||
timezone: location.timezone || undefined,
|
||||
time: location.time || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Controlla se un file esiste fisicamente
|
||||
// ---------------------------------------------------------
|
||||
|
||||
async function fileExists(filePath) {
|
||||
try {
|
||||
await fsp.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Controlla i thumbnail fisici
|
||||
//
|
||||
// FOTO -> min + avg + cast
|
||||
// VIDEO -> min + avg
|
||||
// ---------------------------------------------------------
|
||||
|
||||
async function thumbnailsExist(user, cartella, f) {
|
||||
const thumbBase = path.join(
|
||||
WEB_ROOT,
|
||||
'photos',
|
||||
user,
|
||||
'thumbs',
|
||||
cartella,
|
||||
path.dirname(f.relPath)
|
||||
);
|
||||
|
||||
const parsed = path.parse(f.name);
|
||||
const baseName = parsed.name;
|
||||
|
||||
const thumbMin = path.join(
|
||||
thumbBase,
|
||||
`${baseName}_min.jpg`
|
||||
);
|
||||
|
||||
const thumbAvg = path.join(
|
||||
thumbBase,
|
||||
`${baseName}_avg.jpg`
|
||||
);
|
||||
|
||||
const isVideo = [
|
||||
'.mp4',
|
||||
'.mov',
|
||||
'.m4v'
|
||||
].includes(f.ext.toLowerCase());
|
||||
|
||||
// I video NON hanno cast
|
||||
if (isVideo) {
|
||||
return (
|
||||
await fileExists(thumbMin) &&
|
||||
await fileExists(thumbAvg)
|
||||
);
|
||||
}
|
||||
// Le foto richiedono soltanto MIN e AVG.
|
||||
// La variante CAST viene generata esclusivamente on demand
|
||||
// da POST /share/prepare-cast.
|
||||
return (
|
||||
await fileExists(thumbMin) &&
|
||||
await fileExists(thumbAvg)
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
async function scanPhotoSingle(
|
||||
db,
|
||||
user,
|
||||
cartella,
|
||||
f,
|
||||
newFiles,
|
||||
prefix = '',
|
||||
prev = null
|
||||
) {
|
||||
const fileName = f.name;
|
||||
const id = f.id;
|
||||
const st = f.stat;
|
||||
|
||||
// Hash veloce basato su size + mtime
|
||||
const fastHash = sha256(
|
||||
`${st.size}-${st.mtimeMs}`
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// PATH CAMBIATO = NUOVO FILE
|
||||
// ---------------------------------------------------------
|
||||
|
||||
if (prev && prev.path !== f.path) {
|
||||
log(
|
||||
`${prefix} 🟢 Nuovo/Modificato (path changed): ${fileName}`
|
||||
);
|
||||
|
||||
prev = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// NUOVO FILE
|
||||
// ---------------------------------------------------------
|
||||
|
||||
if (!prev) {
|
||||
const profileStart = process.hrtime.bigint();
|
||||
log(`${prefix} 🟢 Nuovo: ${fileName}`);
|
||||
|
||||
const processStart = process.hrtime.bigint();
|
||||
|
||||
const meta = await processFile(
|
||||
user,
|
||||
cartella,
|
||||
f.relPath,
|
||||
f.absPath,
|
||||
f.ext,
|
||||
st
|
||||
);
|
||||
|
||||
const processMs =
|
||||
Number(process.hrtime.bigint() - processStart) / 1e6;
|
||||
|
||||
log(
|
||||
`⏱️ [PROFILE] ${fileName} processFile=${processMs.toFixed(2)} ms`
|
||||
);
|
||||
|
||||
meta.id = id;
|
||||
meta.path = f.path;
|
||||
|
||||
// Normalizza location prima di salvarla e inviarla
|
||||
meta.location = normalizeLocation(
|
||||
meta.location
|
||||
);
|
||||
|
||||
log(
|
||||
`🌍 [LOCATION NEW] ${fileName} ` +
|
||||
`location=${JSON.stringify(meta.location)}`
|
||||
);
|
||||
|
||||
const dbStart = process.hrtime.bigint();
|
||||
await db('photos').insert({
|
||||
id: meta.id,
|
||||
user,
|
||||
cartella,
|
||||
name: meta.name,
|
||||
path: meta.path,
|
||||
thub1: meta.thub1,
|
||||
thub2: meta.thub2,
|
||||
cast: meta.cast,
|
||||
mime_type: meta.mime_type,
|
||||
width: meta.width,
|
||||
height: meta.height,
|
||||
rotation: meta.rotation,
|
||||
size_bytes: meta.size_bytes,
|
||||
mtimeMs: meta.mtimeMs,
|
||||
duration_ms: meta.duration_ms,
|
||||
taken_at: meta.taken_at,
|
||||
data: meta.data,
|
||||
lat: meta.gps?.lat ?? null,
|
||||
lon: meta.gps?.lng ?? null,
|
||||
alt: meta.gps?.alt ?? null,
|
||||
|
||||
// Qui ora il JSON può contenere:
|
||||
// country, country_code, county, county_code,
|
||||
// region, city, ecc.
|
||||
location: meta.location
|
||||
? JSON.stringify(meta.location)
|
||||
: null,
|
||||
|
||||
fast_hash: fastHash,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const dbMs =
|
||||
Number(process.hrtime.bigint() - dbStart) / 1e6;
|
||||
|
||||
log(
|
||||
`⏱️ [PROFILE] ${fileName} DB insert=${dbMs.toFixed(2)} ms`
|
||||
);
|
||||
|
||||
const geocodeStart = process.hrtime.bigint();
|
||||
await enqueueGeocodeJob({
|
||||
entityType: 'photo',
|
||||
entityId: meta.id,
|
||||
lat: meta.gps?.lat,
|
||||
lon: meta.gps?.lng,
|
||||
});
|
||||
const geocodeMs =
|
||||
Number(process.hrtime.bigint() - geocodeStart) / 1e6;
|
||||
|
||||
const totalMs =
|
||||
Number(process.hrtime.bigint() - profileStart) / 1e6;
|
||||
|
||||
log(
|
||||
`⏱️ [PROFILE] ${fileName} ` +
|
||||
`geocode=${geocodeMs.toFixed(2)} ms ` +
|
||||
`TOTAL=${totalMs.toFixed(2)} ms`
|
||||
);
|
||||
|
||||
newFiles.push(meta);
|
||||
|
||||
return meta;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// IDENTICO (fastHash uguale)
|
||||
//
|
||||
// Se il file è identico:
|
||||
// - se tutti i thumbnail esistono -> non fare niente
|
||||
// - se manca anche un solo thumbnail -> riprocessa
|
||||
//
|
||||
// FOTO -> min + avg + cast
|
||||
// VIDEO -> min + avg
|
||||
// ---------------------------------------------------------
|
||||
|
||||
if (prev.fast_hash === fastHash) {
|
||||
const thumbsOk = await thumbnailsExist(
|
||||
user,
|
||||
cartella,
|
||||
f
|
||||
);
|
||||
|
||||
if (thumbsOk) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// -------------------------------------------------------
|
||||
// File identico ma thumbnail mancanti
|
||||
// -------------------------------------------------------
|
||||
|
||||
log(
|
||||
`${prefix} 🟡 File identico ma thumbnail mancanti: ${fileName}`
|
||||
);
|
||||
|
||||
const meta = await processFile(
|
||||
user,
|
||||
cartella,
|
||||
f.relPath,
|
||||
f.absPath,
|
||||
f.ext,
|
||||
st
|
||||
);
|
||||
|
||||
meta.id = id;
|
||||
meta.path = f.path;
|
||||
|
||||
// Normalizza location
|
||||
meta.location = normalizeLocation(
|
||||
meta.location
|
||||
);
|
||||
|
||||
log(
|
||||
`🌍 [LOCATION THUMBS REBUILD] ${fileName} ` +
|
||||
`location=${JSON.stringify(meta.location)}`
|
||||
);
|
||||
|
||||
await db('photos')
|
||||
.where({ id })
|
||||
.update({
|
||||
name: meta.name,
|
||||
path: meta.path,
|
||||
thub1: meta.thub1,
|
||||
thub2: meta.thub2,
|
||||
cast: meta.cast,
|
||||
mime_type: meta.mime_type,
|
||||
width: meta.width,
|
||||
height: meta.height,
|
||||
rotation: meta.rotation,
|
||||
size_bytes: meta.size_bytes,
|
||||
mtimeMs: meta.mtimeMs,
|
||||
duration_ms: meta.duration_ms,
|
||||
taken_at: meta.taken_at,
|
||||
data: meta.data,
|
||||
lat: meta.gps?.lat ?? null,
|
||||
lon: meta.gps?.lng ?? null,
|
||||
alt: meta.gps?.alt ?? null,
|
||||
|
||||
location: meta.location
|
||||
? JSON.stringify(meta.location)
|
||||
: null,
|
||||
|
||||
fast_hash: fastHash,
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await enqueueGeocodeJob({
|
||||
entityType: 'photo',
|
||||
entityId: meta.id,
|
||||
lat: meta.gps?.lat,
|
||||
lon: meta.gps?.lng,
|
||||
});
|
||||
|
||||
newFiles.push(meta);
|
||||
|
||||
return meta;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// MODIFICATO (fastHash diverso)
|
||||
// ---------------------------------------------------------
|
||||
|
||||
log(`${prefix} 🟠 Modificato: ${fileName}`);
|
||||
|
||||
const meta = await processFile(
|
||||
user,
|
||||
cartella,
|
||||
f.relPath,
|
||||
f.absPath,
|
||||
f.ext,
|
||||
st
|
||||
);
|
||||
|
||||
meta.id = id;
|
||||
meta.path = f.path;
|
||||
|
||||
// Normalizza location prima di salvarla e inviarla
|
||||
meta.location = normalizeLocation(
|
||||
meta.location
|
||||
);
|
||||
|
||||
log(
|
||||
`🌍 [LOCATION UPDATED] ${fileName} ` +
|
||||
`location=${JSON.stringify(meta.location)}`
|
||||
);
|
||||
|
||||
await db('photos')
|
||||
.where({ id })
|
||||
.update({
|
||||
name: meta.name,
|
||||
path: meta.path,
|
||||
thub1: meta.thub1,
|
||||
thub2: meta.thub2,
|
||||
mime_type: meta.mime_type,
|
||||
width: meta.width,
|
||||
height: meta.height,
|
||||
rotation: meta.rotation,
|
||||
size_bytes: meta.size_bytes,
|
||||
mtimeMs: meta.mtimeMs,
|
||||
duration_ms: meta.duration_ms,
|
||||
taken_at: meta.taken_at,
|
||||
data: meta.data,
|
||||
lat: meta.gps?.lat ?? null,
|
||||
lon: meta.gps?.lng ?? null,
|
||||
alt: meta.gps?.alt ?? null,
|
||||
|
||||
// Qui ora il JSON può contenere:
|
||||
// country, country_code, county, county_code,
|
||||
// region, city, ecc.
|
||||
location: meta.location
|
||||
? JSON.stringify(meta.location)
|
||||
: null,
|
||||
|
||||
fast_hash: fastHash,
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await enqueueGeocodeJob({
|
||||
entityType: 'photo',
|
||||
entityId: meta.id,
|
||||
lat: meta.gps?.lat,
|
||||
lon: meta.gps?.lng,
|
||||
});
|
||||
|
||||
newFiles.push(meta);
|
||||
|
||||
return meta;
|
||||
}
|
||||
|
||||
module.exports = scanPhotoSingle;
|
||||
143
api_v1/scanner/scanPhotosUser.js
Normal file
143
api_v1/scanner/scanPhotosUser.js
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
'use strict';
|
||||
|
||||
// Scansione completa: individua i file e li accoda.
|
||||
// L'elaborazione effettiva è responsabilità di photoIngestWorker.
|
||||
|
||||
const path = require('path');
|
||||
const fsp = require('fs/promises');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const { WEB_ROOT, SUPPORTED_EXTS } = require('../config');
|
||||
const { log } = require('./logger');
|
||||
const scanCartella = require('./scanCartella');
|
||||
const {
|
||||
ensurePhotoIngestQueue,
|
||||
enqueuePhotoIngestJob,
|
||||
} = require('./photoIngestQueue');
|
||||
|
||||
function sha256(value) {
|
||||
return crypto.createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
async function thumbnailsExist(userName, cartella, relPath) {
|
||||
const parsed = path.parse(relPath);
|
||||
const thumbDir = path.resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
WEB_ROOT,
|
||||
'photos',
|
||||
userName,
|
||||
'thumbs',
|
||||
cartella,
|
||||
parsed.dir
|
||||
);
|
||||
|
||||
const base = path.join(thumbDir, parsed.name);
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
fsp.access(`${base}_min.jpg`),
|
||||
fsp.access(`${base}_avg.jpg`),
|
||||
]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function scanPhotosUser(userName, db) {
|
||||
log(`🔵 Inizio scansione e accodamento per user=${userName}`);
|
||||
|
||||
const photosRoot = path.resolve(__dirname, '..', '..', WEB_ROOT, 'photos');
|
||||
const userDir = path.join(photosRoot, userName, 'original');
|
||||
|
||||
let entries;
|
||||
|
||||
try {
|
||||
entries = await fsp.readdir(userDir, { withFileTypes: true });
|
||||
} catch {
|
||||
log(`❌ Nessuna directory per utente ${userName}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const folders = entries
|
||||
.filter(entry => entry.isDirectory())
|
||||
.map(entry => entry.name);
|
||||
|
||||
// Mantiene la pulizia delle cartelle eliminate dal filesystem.
|
||||
const { deleteFolderForUser } = require('./deleteFolder')(db);
|
||||
|
||||
const dbFolders = await db('photos')
|
||||
.where({ user: userName })
|
||||
.distinct('cartella')
|
||||
.pluck('cartella');
|
||||
|
||||
for (const dbFolder of dbFolders) {
|
||||
if (!folders.includes(dbFolder)) {
|
||||
log(`🗑️ Cartella rimossa dal filesystem: ${dbFolder}`);
|
||||
await deleteFolderForUser(userName, dbFolder);
|
||||
}
|
||||
}
|
||||
|
||||
await ensurePhotoIngestQueue(db);
|
||||
|
||||
let queued = 0;
|
||||
let skipped = 0;
|
||||
|
||||
|
||||
for (const cartella of folders) {
|
||||
const absCartella = path.join(userDir, cartella);
|
||||
log(`📁 Controllo cartella: ${cartella}`);
|
||||
|
||||
for await (const file of scanCartella(userName, cartella, absCartella, db)) {
|
||||
|
||||
const previous = await db('photos')
|
||||
.select('id', 'size_bytes', 'mtimeMs', 'fast_hash', 'path')
|
||||
.where({ id: file.id })
|
||||
.first();
|
||||
|
||||
const expectedHash = sha256(`${file.stat.size}-${file.stat.mtimeMs}`);
|
||||
const expectedPath = file.path;
|
||||
|
||||
let needsProcessing =
|
||||
!previous ||
|
||||
previous.path !== expectedPath ||
|
||||
previous.fast_hash !== expectedHash;
|
||||
|
||||
if (!needsProcessing) {
|
||||
needsProcessing = !(await thumbnailsExist(
|
||||
userName,
|
||||
cartella,
|
||||
file.relPath
|
||||
));
|
||||
}
|
||||
|
||||
if (needsProcessing) {
|
||||
await enqueuePhotoIngestJob(db, {
|
||||
user: userName,
|
||||
cartella,
|
||||
relPath: file.relPath,
|
||||
mtimeMs: file.stat.mtimeMs,
|
||||
sizeBytes: file.stat.size,
|
||||
});
|
||||
queued++;
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
log(
|
||||
`🟣 Scansione/accodamento terminato per ${userName}: ` +
|
||||
`${queued} accodati, ${skipped} invariati`
|
||||
);
|
||||
|
||||
// Restituisce un array per compatibilità con chi già chiama questa funzione.
|
||||
// Non contiene risultati elaborati: quelli arriveranno dal worker.
|
||||
return [];
|
||||
}
|
||||
|
||||
module.exports = scanPhotosUser;
|
||||
25
api_v1/scanner/scanUser.js
Normal file
25
api_v1/scanner/scanUser.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// api_v1/scanner/scanUser.js
|
||||
const scanPhotosUser = require('./scanPhotosUser');
|
||||
const scanActivitiesUser = require('./scanActivitiesUser');
|
||||
const { log } = require('./logger');
|
||||
|
||||
async function scanUser(userName, db) {
|
||||
log(`🔵 Inizio scan COMPLETO per user=${userName}`);
|
||||
|
||||
// 🔥 SCAN FOTO
|
||||
const newPhotos = await scanPhotosUser(userName, db);
|
||||
|
||||
// 🔥 SCAN ATTIVITÀ
|
||||
const newActivities = await scanActivitiesUser(userName, db);
|
||||
|
||||
log(`🟣 Scan COMPLETATO per user=${userName}`);
|
||||
log(` 📸 Nuove foto: ${newPhotos.length}`);
|
||||
log(` 🏃♂️ Nuove attività: ${newActivities.length}`);
|
||||
|
||||
return {
|
||||
photos: newPhotos,
|
||||
activities: newActivities
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = scanUser;
|
||||
288
api_v1/scanner/thumbs.js
Normal file
288
api_v1/scanner/thumbs.js
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
// api_v1/scanner/thumbs.js
|
||||
|
||||
const fs = require('fs');
|
||||
const sharp = require('sharp');
|
||||
const { exec } = require('child_process');
|
||||
const path = require('path');
|
||||
|
||||
/*
|
||||
* ============================================================
|
||||
* CONFIGURAZIONE IMMAGINE CAST
|
||||
* ============================================================
|
||||
*/
|
||||
|
||||
/*
|
||||
* Se il file originale è JPEG e pesa meno di questa soglia,
|
||||
* il file _cast.jpg sarà una copia identica dell'originale.
|
||||
*
|
||||
* 500 * 1024 = 512000 byte.
|
||||
*/
|
||||
const CAST_JPEG_COPY_LIMIT_BYTES = 500 * 1024;
|
||||
|
||||
/*
|
||||
* Dimensioni massime dell'immagine Cast.
|
||||
*
|
||||
* L'immagine viene sempre contenuta dentro questo rettangolo,
|
||||
* senza crop e senza ingrandimento.
|
||||
*/
|
||||
const CAST_MAX_WIDTH = 1920;
|
||||
const CAST_MAX_HEIGHT = 1080;
|
||||
|
||||
/*
|
||||
* Qualità JPEG usata quando è necessaria la conversione.
|
||||
*/
|
||||
const CAST_JPEG_QUALITY = 85;
|
||||
|
||||
|
||||
/*
|
||||
* Video: crea soltanto AVG e MIN.
|
||||
*/
|
||||
function createVideoThumbnail(
|
||||
videoPath,
|
||||
thumbMinPath,
|
||||
thumbAvgPath
|
||||
) {
|
||||
return new Promise(resolve => {
|
||||
const cmd = `
|
||||
ffmpeg -y -i "${videoPath}" -ss 00:00:01.000 -vframes 1 "${thumbAvgPath}" &&
|
||||
ffmpeg -y -i "${thumbAvgPath}" -vf "scale=100:-1" "${thumbMinPath}"
|
||||
`;
|
||||
|
||||
exec(cmd, error => {
|
||||
if (error) {
|
||||
console.error(
|
||||
'[THUMB VIDEO] errore creazione thumbnail',
|
||||
{
|
||||
videoPath,
|
||||
thumbMinPath,
|
||||
thumbAvgPath,
|
||||
error: error.message,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
'[THUMB VIDEO] thumbnails create',
|
||||
{
|
||||
videoPath,
|
||||
thumbMinPath,
|
||||
thumbAvgPath,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* Mantiene il comportamento originale:
|
||||
* la Promise viene risolta anche in caso di errore.
|
||||
*/
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* Crea la versione CAST dell'immagine.
|
||||
*
|
||||
* Regole:
|
||||
*
|
||||
* 1. Se l'originale è JPEG e pesa meno di 500 KB:
|
||||
* copia byte per byte l'originale nel file _cast.jpg.
|
||||
*
|
||||
* 2. In tutti gli altri casi:
|
||||
* converte l'immagine in JPEG qualità 85.
|
||||
*
|
||||
* 3. Se l'immagine supera 1920x1080:
|
||||
* viene ridimensionata per stare dentro 1920x1080.
|
||||
*
|
||||
* 4. Se l'immagine è già più piccola:
|
||||
* mantiene le sue dimensioni, senza ingrandirla.
|
||||
*
|
||||
* 5. Non viene mai effettuato crop.
|
||||
*/
|
||||
async function createCastImage(
|
||||
filePath,
|
||||
finalCastPath
|
||||
) {
|
||||
const sourceStat =
|
||||
await fs.promises.stat(filePath);
|
||||
|
||||
const metadata =
|
||||
await sharp(filePath).metadata();
|
||||
|
||||
/*
|
||||
* metadata.format === "jpeg" riconosce il contenuto reale
|
||||
* dell'immagine, indipendentemente dall'estensione .jpg o .jpeg.
|
||||
*/
|
||||
const sourceIsJpeg =
|
||||
metadata.format === 'jpeg';
|
||||
|
||||
const sourceIsUnderCopyLimit =
|
||||
sourceStat.size <
|
||||
CAST_JPEG_COPY_LIMIT_BYTES;
|
||||
|
||||
/*
|
||||
* JPEG sotto 500 KB:
|
||||
* copia identica dell'originale.
|
||||
*
|
||||
* Non viene effettuata:
|
||||
*
|
||||
* - ricompressione;
|
||||
* - rotazione;
|
||||
* - modifica dei metadati;
|
||||
* - modifica delle dimensioni.
|
||||
*/
|
||||
if (
|
||||
sourceIsJpeg &&
|
||||
sourceIsUnderCopyLimit
|
||||
) {
|
||||
await fs.promises.copyFile(
|
||||
filePath,
|
||||
finalCastPath
|
||||
);
|
||||
|
||||
const castStat =
|
||||
await fs.promises.stat(finalCastPath);
|
||||
|
||||
console.log(
|
||||
'[THUMB CAST] originale JPEG copiato',
|
||||
{
|
||||
source: filePath,
|
||||
destination: finalCastPath,
|
||||
sourceBytes: sourceStat.size,
|
||||
castBytes: castStat.size,
|
||||
copyLimitBytes:
|
||||
CAST_JPEG_COPY_LIMIT_BYTES,
|
||||
sourceFormat: metadata.format,
|
||||
sourceWidth: metadata.width || null,
|
||||
sourceHeight: metadata.height || null,
|
||||
}
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* Tutti gli altri casi:
|
||||
*
|
||||
* - JPEG da almeno 500 KB;
|
||||
* - PNG;
|
||||
* - WebP;
|
||||
* - HEIC/HEIF, se supportato da Sharp;
|
||||
* - TIFF;
|
||||
* - altri formati decodificabili da Sharp.
|
||||
*
|
||||
* L'immagine viene convertita in JPEG qualità 85.
|
||||
*
|
||||
* withoutEnlargement: true garantisce che un'immagine più
|
||||
* piccola di 1920x1080 mantenga le proprie dimensioni.
|
||||
*/
|
||||
await sharp(filePath)
|
||||
.rotate()
|
||||
.resize({
|
||||
width: CAST_MAX_WIDTH,
|
||||
height: CAST_MAX_HEIGHT,
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true,
|
||||
fastShrinkOnLoad: true,
|
||||
})
|
||||
.jpeg({
|
||||
quality: CAST_JPEG_QUALITY,
|
||||
progressive: false,
|
||||
optimizeCoding: true,
|
||||
})
|
||||
.toFile(finalCastPath);
|
||||
|
||||
const castStat =
|
||||
await fs.promises.stat(finalCastPath);
|
||||
|
||||
const castMetadata =
|
||||
await sharp(finalCastPath).metadata();
|
||||
|
||||
console.log(
|
||||
'[THUMB CAST] immagine convertita',
|
||||
{
|
||||
source: filePath,
|
||||
destination: finalCastPath,
|
||||
sourceBytes: sourceStat.size,
|
||||
castBytes: castStat.size,
|
||||
sourceFormat:
|
||||
metadata.format || null,
|
||||
sourceWidth:
|
||||
metadata.width || null,
|
||||
sourceHeight:
|
||||
metadata.height || null,
|
||||
castFormat:
|
||||
castMetadata.format || null,
|
||||
castWidth:
|
||||
castMetadata.width || null,
|
||||
castHeight:
|
||||
castMetadata.height || null,
|
||||
quality: CAST_JPEG_QUALITY,
|
||||
maxWidth: CAST_MAX_WIDTH,
|
||||
maxHeight: CAST_MAX_HEIGHT,
|
||||
reason: sourceIsJpeg
|
||||
? 'JPEG sopra o uguale alla soglia'
|
||||
: 'formato originale non JPEG',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* Foto: crea MIN, AVG e CAST.
|
||||
*/
|
||||
async function createThumbnails(
|
||||
filePath,
|
||||
thumbMinPath,
|
||||
thumbAvgPath
|
||||
) {
|
||||
try {
|
||||
|
||||
const avgBuffer = await sharp(filePath)
|
||||
.rotate()
|
||||
.resize({
|
||||
width: 400,
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.jpeg({
|
||||
quality: 78,
|
||||
progressive: false,
|
||||
optimizeCoding: true,
|
||||
})
|
||||
.toBuffer();
|
||||
|
||||
await Promise.all([
|
||||
fs.promises.writeFile(thumbAvgPath, avgBuffer),
|
||||
|
||||
sharp(avgBuffer)
|
||||
.resize({
|
||||
width: 100,
|
||||
height: 100,
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.jpeg({
|
||||
quality: 75,
|
||||
progressive: false,
|
||||
optimizeCoding: true,
|
||||
})
|
||||
.toFile(thumbMinPath),
|
||||
]);
|
||||
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'[THUMB] errore creazione thumbnails',
|
||||
{
|
||||
filePath,
|
||||
thumbMinPath,
|
||||
thumbAvgPath,
|
||||
error: err.message,
|
||||
stack: err.stack,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createVideoThumbnail,
|
||||
createThumbnails,
|
||||
};
|
||||
41
api_v1/scanner/utils.js
Normal file
41
api_v1/scanner/utils.js
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
function toPosix(p) {
|
||||
return p.split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function sha256(s) {
|
||||
return crypto.createHash('sha256').update(s).digest('hex');
|
||||
}
|
||||
|
||||
function inferMimeFromExt(ext) {
|
||||
switch (ext.toLowerCase()) {
|
||||
case '.jpg':
|
||||
case '.jpeg': return 'image/jpeg';
|
||||
case '.png': return 'image/png';
|
||||
case '.webp': return 'image/webp';
|
||||
case '.heic':
|
||||
case '.heif': return 'image/heic';
|
||||
case '.mp4': return 'video/mp4';
|
||||
case '.mov': return 'video/quicktime';
|
||||
case '.m4v': return 'video/x-m4v';
|
||||
default: return 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
|
||||
function parseExifDateUtc(s) {
|
||||
if (!s) return null;
|
||||
const re = /^(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2}):(\d{2})$/;
|
||||
const m = re.exec(s);
|
||||
if (!m) return null;
|
||||
const dt = new Date(Date.UTC(+m[1], +m[2]-1, +m[3], +m[4], +m[5], +m[6]));
|
||||
return dt.toISOString();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
toPosix,
|
||||
sha256,
|
||||
inferMimeFromExt,
|
||||
parseExifDateUtc
|
||||
};
|
||||
17
api_v1/scanner/video.js
Normal file
17
api_v1/scanner/video.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
const { exec } = require('child_process');
|
||||
|
||||
function probeVideo(videoPath) {
|
||||
return new Promise((resolve) => {
|
||||
const cmd = `ffprobe -v quiet -print_format json -show_format -show_streams "${videoPath}"`;
|
||||
exec(cmd, (err, stdout) => {
|
||||
if (err) return resolve({});
|
||||
try {
|
||||
resolve(JSON.parse(stdout));
|
||||
} catch {
|
||||
resolve({});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { probeVideo };
|
||||
17
api_v1/scanner/writeJsonAtomic.js
Normal file
17
api_v1/scanner/writeJsonAtomic.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
// api_v1/scanner/writeJsonAtomic.js
|
||||
const path = require('path');
|
||||
const fsp = require('fs/promises');
|
||||
|
||||
async function writeJsonAtomic(filePath, data) {
|
||||
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.${Math.random()
|
||||
.toString(16)
|
||||
.slice(2)}.tmp`;
|
||||
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
|
||||
await fsp.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fsp.writeFile(tmpPath, json, 'utf8');
|
||||
await fsp.rename(tmpPath, filePath);
|
||||
}
|
||||
|
||||
module.exports = writeJsonAtomic;
|
||||
51
api_v1/tools.js
Normal file
51
api_v1/tools.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* Required libraries
|
||||
*/
|
||||
const bcrypt = require('bcrypt')
|
||||
const readLine = require('readline')
|
||||
const async = require('async')
|
||||
|
||||
// Password hash method
|
||||
const hashPassword = plain => bcrypt.hashSync(plain, 8)
|
||||
|
||||
// Ask user password method
|
||||
function askPassword(question) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const rl = readLine.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
})
|
||||
|
||||
rl.question(question, answer => {
|
||||
rl.close()
|
||||
resolve(answer)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Generate hash password method
|
||||
async function generateHash() {
|
||||
try {
|
||||
console.log('**********************************')
|
||||
console.log('** Password hash script **')
|
||||
console.log('**********************************')
|
||||
|
||||
const passwordAnswer = await askPassword(
|
||||
'Please give me a password to hash: '
|
||||
)
|
||||
|
||||
if (passwordAnswer != '') {
|
||||
const hashedPassword = hashPassword(passwordAnswer)
|
||||
const compare = bcrypt.compareSync(passwordAnswer, hashedPassword)
|
||||
await console.log('Hashed password:', hashedPassword)
|
||||
await console.log('Valdiation:', compare)
|
||||
} else {
|
||||
console.log('You need write something. Script aborted!')
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
return process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
generateHash()
|
||||
159
api_v1/userAccess.js
Normal file
159
api_v1/userAccess.js
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const usersPath = path.resolve(__dirname, 'users.json');
|
||||
|
||||
function normalize(value) {
|
||||
return String(value ?? '').trim();
|
||||
}
|
||||
|
||||
function unique(values) {
|
||||
return [
|
||||
...new Set(
|
||||
values
|
||||
.map(normalize)
|
||||
.filter(Boolean)
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
function loadUsers() {
|
||||
const raw = fs.readFileSync(usersPath, 'utf8');
|
||||
const data = JSON.parse(raw);
|
||||
|
||||
if (!data || !Array.isArray(data.users)) {
|
||||
throw new Error('Formato users.json non valido');
|
||||
}
|
||||
|
||||
return data.users;
|
||||
}
|
||||
|
||||
function findUserByName(userName) {
|
||||
const requestedName = normalize(userName).toLowerCase();
|
||||
|
||||
if (!requestedName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
loadUsers().find((user) => {
|
||||
return normalize(user.name).toLowerCase() === requestedName;
|
||||
}) || null
|
||||
);
|
||||
}
|
||||
|
||||
function getUserAccess(userName) {
|
||||
const user = findUserByName(userName);
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const devices = Array.isArray(user.devices)
|
||||
? unique(user.devices)
|
||||
: [];
|
||||
|
||||
const usersR = Array.isArray(user.usersR)
|
||||
? unique(user.usersR)
|
||||
: [];
|
||||
|
||||
const usersW = Array.isArray(user.usersW)
|
||||
? unique(user.usersW)
|
||||
: [];
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: normalize(user.name),
|
||||
devices,
|
||||
usersR,
|
||||
usersW,
|
||||
|
||||
// Contenuti leggibili nella galleria
|
||||
readableSources: unique([
|
||||
...usersR,
|
||||
...devices
|
||||
]),
|
||||
|
||||
// Contenuti modificabili
|
||||
writableSources: unique([
|
||||
...usersW,
|
||||
...devices
|
||||
])
|
||||
};
|
||||
}
|
||||
|
||||
function getReadableSources(userName) {
|
||||
const access = getUserAccess(userName);
|
||||
|
||||
return access
|
||||
? access.readableSources
|
||||
: [];
|
||||
}
|
||||
|
||||
function getWritableSources(userName) {
|
||||
const access = getUserAccess(userName);
|
||||
|
||||
return access
|
||||
? access.writableSources
|
||||
: [];
|
||||
}
|
||||
|
||||
function filterReadableSources(userName, requestedSources) {
|
||||
const readableSources = getReadableSources(userName);
|
||||
|
||||
if (!requestedSources) {
|
||||
return readableSources;
|
||||
}
|
||||
|
||||
const requested = Array.isArray(requestedSources)
|
||||
? requestedSources
|
||||
: [requestedSources];
|
||||
|
||||
const readableMap = new Map(
|
||||
readableSources.map((source) => [
|
||||
source.toLowerCase(),
|
||||
source
|
||||
])
|
||||
);
|
||||
|
||||
return unique(requested)
|
||||
.map((source) => {
|
||||
return readableMap.get(source.toLowerCase());
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function canRead(userName, sourceName) {
|
||||
const source = normalize(sourceName).toLowerCase();
|
||||
|
||||
if (!source) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return getReadableSources(userName).some((allowedSource) => {
|
||||
return allowedSource.toLowerCase() === source;
|
||||
});
|
||||
}
|
||||
|
||||
function canWrite(userName, sourceName) {
|
||||
const source = normalize(sourceName).toLowerCase();
|
||||
|
||||
if (!source) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return getWritableSources(userName).some((allowedSource) => {
|
||||
return allowedSource.toLowerCase() === source;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
loadUsers,
|
||||
findUserByName,
|
||||
getUserAccess,
|
||||
getReadableSources,
|
||||
getWritableSources,
|
||||
filterReadableSources,
|
||||
canRead,
|
||||
canWrite
|
||||
};
|
||||
31
api_v1/users.json
Normal file
31
api_v1/users.json
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"users": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Admin",
|
||||
"email": "admin@gmail.com",
|
||||
"password": "$2b$08$g0UWN6RnN7e.8rX3fuXSSOSJDTvucu./0FAU.yXp0wx4SJXyeaU3.",
|
||||
"devices": [],
|
||||
"usersR": ["Common", "Fabio", "Jessica", "Fabio13TPro", "Fabio11TPro"],
|
||||
"usersW": ["Common", "Fabio", "Jessica", "Fabio13TPro", "Fabio11TPro"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Fabio",
|
||||
"email": "fabio@gmail.com",
|
||||
"password": "$2b$08$g0UWN6RnN7e.8rX3fuXSSOSJDTvucu./0FAU.yXp0wx4SJXyeaU3.",
|
||||
"devices": ["Fabio13TPro", "Fabio11TPro"],
|
||||
"usersR": ["Common", "Fabio"],
|
||||
"usersW": ["Fabio"]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Jessica",
|
||||
"email": "jessie@libero.it",
|
||||
"password": "$2b$08$g0UWN6RnN7e.8rX3fuXSSOSJDTvucu./0FAU.yXp0wx4SJXyeaU3.",
|
||||
"devices": [],
|
||||
"usersR": ["Common", "Jessica"],
|
||||
"usersW": ["Jessica"]
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
bin/face_scan
Executable file
BIN
bin/face_scan
Executable file
Binary file not shown.
51
clear.sh
Executable file
51
clear.sh
Executable file
|
|
@ -0,0 +1,51 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "=== RESET s175js ==="
|
||||
echo "Directory: $(pwd)"
|
||||
|
||||
# Verifica i PID registrati nei lock prima di rimuoverli.
|
||||
for file in face-scan-daemon.lock prototype-worker-daemon.lock geocode-daemon.lock; do
|
||||
[ -f "$file" ] || continue
|
||||
|
||||
pid=""
|
||||
if [[ "$file" == "prototype-worker-daemon.lock" ]]; then
|
||||
pid=$(tr -cd '0-9' < "$file")
|
||||
else
|
||||
pid=$(node -e '
|
||||
try {
|
||||
const x = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
|
||||
console.log(x.pid || "");
|
||||
} catch {}
|
||||
' "$file")
|
||||
fi
|
||||
|
||||
if [[ -n "$pid" && -d "/proc/$pid" ]]; then
|
||||
echo "ERRORE: $file segnala il PID attivo $pid. Reset annullato."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Backup del database prima della cancellazione.
|
||||
if [ -f api_v1/database.sqlite ]; then
|
||||
backup="api_v1/database.sqlite.pre-clear-$(date +%Y%m%d-%H%M%S)"
|
||||
cp api_v1/database.sqlite "$backup"
|
||||
echo "Backup creato: $backup"
|
||||
fi
|
||||
|
||||
rm -f \
|
||||
face-scan-daemon.lock \
|
||||
prototype-worker-daemon.lock \
|
||||
geocode-daemon.lock \
|
||||
api_v1/database.sqlite \
|
||||
api_v1/database.sqlite-wal \
|
||||
api_v1/database.sqlite-shm
|
||||
|
||||
echo "=== INIZIALIZZAZIONE DATABASE ==="
|
||||
node db/init.js
|
||||
|
||||
echo "=== CONTROLLO INTEGRITÀ ==="
|
||||
sqlite3 api_v1/database.sqlite "PRAGMA integrity_check;"
|
||||
|
||||
echo "=== RESET COMPLETATO ==="
|
||||
209
db/activities.js
Normal file
209
db/activities.js
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
// db/activities.js
|
||||
const knex = require('./knex');
|
||||
|
||||
/* ============================================================
|
||||
INSERT OR UPDATE ACTIVITY
|
||||
============================================================ */
|
||||
async function insertOrUpdateActivity(row) {
|
||||
const exists = await knex('activities').where({ id: row.id }).first();
|
||||
|
||||
if (!exists) {
|
||||
await knex('activities').insert({
|
||||
id: row.id,
|
||||
user: row.user,
|
||||
cartella: row.cartella,
|
||||
|
||||
file_name: row.file_name,
|
||||
file_extension: row.file_extension,
|
||||
format: row.format,
|
||||
display_name: row.display_name,
|
||||
route_name: row.route_name,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
notes: row.notes,
|
||||
sport: row.sport,
|
||||
activity_type: row.activity_type,
|
||||
device_manufacturer: row.device_manufacturer,
|
||||
device_model: row.device_model,
|
||||
source_app: row.source_app,
|
||||
|
||||
start_time_millis: row.start_time_millis,
|
||||
end_time_millis: row.end_time_millis,
|
||||
duration_millis: row.duration_millis,
|
||||
|
||||
distance_meters: row.distance_meters,
|
||||
elevation_gain_meters: row.elevation_gain_meters,
|
||||
elevation_loss_meters: row.elevation_loss_meters,
|
||||
|
||||
min_elevation_meters: row.min_elevation_meters,
|
||||
max_elevation_meters: row.max_elevation_meters,
|
||||
|
||||
file_distance_meters: row.file_distance_meters,
|
||||
file_elevation_gain_meters: row.file_elevation_gain_meters,
|
||||
|
||||
computed_distance_meters: row.computed_distance_meters,
|
||||
computed_elevation_gain_meters: row.computed_elevation_gain_meters,
|
||||
|
||||
average_speed_mps: row.average_speed_mps,
|
||||
max_speed_mps: row.max_speed_mps,
|
||||
average_pace_sec_per_km: row.average_pace_sec_per_km,
|
||||
|
||||
average_heart_rate: row.average_heart_rate,
|
||||
max_heart_rate: row.max_heart_rate,
|
||||
average_cadence: row.average_cadence,
|
||||
max_cadence: row.max_cadence,
|
||||
|
||||
calories: row.calories,
|
||||
power_avg: row.power_avg,
|
||||
power_max: row.power_max,
|
||||
|
||||
point_count: row.point_count,
|
||||
segment_count: row.segment_count,
|
||||
lap_count: row.lap_count,
|
||||
|
||||
min_latitude: row.min_latitude,
|
||||
max_latitude: row.max_latitude,
|
||||
min_longitude: row.min_longitude,
|
||||
max_longitude: row.max_longitude,
|
||||
center_latitude: row.center_latitude,
|
||||
center_longitude: row.center_longitude,
|
||||
|
||||
size_bytes: row.size_bytes,
|
||||
last_modified_millis: row.last_modified_millis,
|
||||
date_added_millis: row.date_added_millis,
|
||||
date_modified_millis: row.date_modified_millis,
|
||||
last_scan_millis: row.last_scan_millis,
|
||||
|
||||
is_favorite: row.is_favorite,
|
||||
is_hidden: row.is_hidden,
|
||||
rating: row.rating,
|
||||
|
||||
tags: row.tags ? JSON.stringify(row.tags) : null,
|
||||
scan_status: row.scan_status,
|
||||
scan_error: row.scan_error,
|
||||
diagnostics: row.diagnostics ? JSON.stringify(row.diagnostics) : null,
|
||||
location: row.location ? JSON.stringify(row.location) : null,
|
||||
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now()
|
||||
});
|
||||
} else {
|
||||
await knex('activities').where({ id: row.id }).update({
|
||||
// aggiorna solo i campi che possono cambiare
|
||||
file_name: row.file_name || exists.file_name,
|
||||
file_extension: row.file_extension || exists.file_extension,
|
||||
format: row.format || exists.format,
|
||||
display_name: row.display_name || exists.display_name,
|
||||
route_name: row.route_name || exists.route_name,
|
||||
title: row.title || exists.title,
|
||||
description: row.description || exists.description,
|
||||
notes: row.notes || exists.notes,
|
||||
sport: row.sport || exists.sport,
|
||||
activity_type: row.activity_type || exists.activity_type,
|
||||
device_manufacturer: row.device_manufacturer || exists.device_manufacturer,
|
||||
device_model: row.device_model || exists.device_model,
|
||||
source_app: row.source_app || exists.source_app,
|
||||
|
||||
start_time_millis: row.start_time_millis || exists.start_time_millis,
|
||||
end_time_millis: row.end_time_millis || exists.end_time_millis,
|
||||
duration_millis: row.duration_millis || exists.duration_millis,
|
||||
|
||||
distance_meters: row.distance_meters || exists.distance_meters,
|
||||
elevation_gain_meters: row.elevation_gain_meters || exists.elevation_gain_meters,
|
||||
elevation_loss_meters: row.elevation_loss_meters || exists.elevation_loss_meters,
|
||||
|
||||
min_elevation_meters: row.min_elevation_meters || exists.min_elevation_meters,
|
||||
max_elevation_meters: row.max_elevation_meters || exists.max_elevation_meters,
|
||||
|
||||
file_distance_meters: row.file_distance_meters || exists.file_distance_meters,
|
||||
file_elevation_gain_meters: row.file_elevation_gain_meters || exists.file_elevation_gain_meters,
|
||||
|
||||
computed_distance_meters: row.computed_distance_meters || exists.computed_distance_meters,
|
||||
computed_elevation_gain_meters: row.computed_elevation_gain_meters || exists.computed_elevation_gain_meters,
|
||||
|
||||
average_speed_mps: row.average_speed_mps || exists.average_speed_mps,
|
||||
max_speed_mps: row.max_speed_mps || exists.max_speed_mps,
|
||||
average_pace_sec_per_km: row.average_pace_sec_per_km || exists.average_pace_sec_per_km,
|
||||
|
||||
average_heart_rate: row.average_heart_rate || exists.average_heart_rate,
|
||||
max_heart_rate: row.max_heart_rate || exists.max_heart_rate,
|
||||
average_cadence: row.average_cadence || exists.average_cadence,
|
||||
max_cadence: row.max_cadence || exists.max_cadence,
|
||||
|
||||
calories: row.calories || exists.calories,
|
||||
power_avg: row.power_avg || exists.power_avg,
|
||||
power_max: row.power_max || exists.power_max,
|
||||
|
||||
point_count: row.point_count || exists.point_count,
|
||||
segment_count: row.segment_count || exists.segment_count,
|
||||
lap_count: row.lap_count || exists.lap_count,
|
||||
|
||||
min_latitude: row.min_latitude || exists.min_latitude,
|
||||
max_latitude: row.max_latitude || exists.max_latitude,
|
||||
min_longitude: row.min_longitude || exists.min_longitude,
|
||||
max_longitude: row.max_longitude || exists.max_longitude,
|
||||
center_latitude: row.center_latitude || exists.center_latitude,
|
||||
center_longitude: row.center_longitude || exists.center_longitude,
|
||||
|
||||
size_bytes: row.size_bytes || exists.size_bytes,
|
||||
last_modified_millis: row.last_modified_millis || exists.last_modified_millis,
|
||||
date_added_millis: row.date_added_millis || exists.date_added_millis,
|
||||
date_modified_millis: row.date_modified_millis || exists.date_modified_millis,
|
||||
last_scan_millis: row.last_scan_millis || exists.last_scan_millis,
|
||||
|
||||
is_favorite: row.is_favorite ?? exists.is_favorite,
|
||||
is_hidden: row.is_hidden ?? exists.is_hidden,
|
||||
rating: row.rating ?? exists.rating,
|
||||
|
||||
tags: row.tags ? JSON.stringify(row.tags) : exists.tags,
|
||||
scan_status: row.scan_status || exists.scan_status,
|
||||
scan_error: row.scan_error || exists.scan_error,
|
||||
diagnostics: row.diagnostics ? JSON.stringify(row.diagnostics) : exists.diagnostics,
|
||||
location: row.location ? JSON.stringify(row.location) : exists.location,
|
||||
|
||||
updated_at: knex.fn.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
HARD DELETE
|
||||
============================================================ */
|
||||
async function markActivityDeletedHard(id, user) {
|
||||
await knex('activities').where({ id, user }).del();
|
||||
await knex('deleted_hard_activities')
|
||||
.insert({ id, user, deleted_at: knex.fn.now() })
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
CHANGES SINCE
|
||||
============================================================ */
|
||||
async function getActivitiesSince(user, sinceIso, limit = 1000) {
|
||||
return knex('activities')
|
||||
.where({ user })
|
||||
.andWhere('updated_at', '>', sinceIso)
|
||||
.orderBy('updated_at', 'asc')
|
||||
.limit(limit)
|
||||
.select('*');
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
DELETED HARD SINCE
|
||||
============================================================ */
|
||||
async function getDeletedHardActivitiesSince(user, sinceIso) {
|
||||
return knex('deleted_hard_activities')
|
||||
.where({ user })
|
||||
.andWhere('deleted_at', '>', sinceIso)
|
||||
.select('id', 'deleted_at');
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
EXPORT
|
||||
============================================================ */
|
||||
module.exports = {
|
||||
insertOrUpdateActivity,
|
||||
markActivityDeletedHard,
|
||||
getActivitiesSince,
|
||||
getDeletedHardActivitiesSince
|
||||
};
|
||||
142
db/dbWs.js
Normal file
142
db/dbWs.js
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
// db/dbWs.js
|
||||
console.log(">>> dbWs.js CARICATO:", __filename);
|
||||
const knex = require("./knex");
|
||||
|
||||
// ===============================
|
||||
// SESSIONI
|
||||
// ===============================
|
||||
|
||||
async function upsertSession(session_id, user, device_id) {
|
||||
const now = Date.now();
|
||||
const existing = await knex("ws_sessions")
|
||||
.where({ session_id, device_id })
|
||||
.first();
|
||||
|
||||
if (!existing) {
|
||||
await knex("ws_sessions").insert({
|
||||
session_id,
|
||||
user,
|
||||
device_id,
|
||||
connected_at: now,
|
||||
last_ack: now,
|
||||
last_sync: null,
|
||||
need_full_sync: false
|
||||
});
|
||||
} else {
|
||||
await knex("ws_sessions")
|
||||
.where({ session_id, device_id })
|
||||
.update({
|
||||
user,
|
||||
connected_at: now,
|
||||
last_ack: now
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function getDevicesForSession(session_id) {
|
||||
const rows = await knex('ws_sessions')
|
||||
.where({ session_id })
|
||||
.select('device_id');
|
||||
|
||||
return rows.map(r => r.device_id);
|
||||
}
|
||||
|
||||
async function getSession(session_id, device_id) {
|
||||
return knex("ws_sessions")
|
||||
.where({ session_id, device_id })
|
||||
.first();
|
||||
}
|
||||
|
||||
async function updateSessionAck(session_id, device_id) {
|
||||
return knex("ws_sessions")
|
||||
.where({ session_id, device_id })
|
||||
.update({ last_ack: Date.now() });
|
||||
}
|
||||
|
||||
async function setSessionNeedFullSync(session_id, device_id, value) {
|
||||
return knex("ws_sessions")
|
||||
.where({ session_id, device_id })
|
||||
.update({ need_full_sync: value });
|
||||
}
|
||||
|
||||
async function clearNeedFullSync(session_id, device_id) {
|
||||
return knex("ws_sessions")
|
||||
.where({ session_id, device_id })
|
||||
.update({ need_full_sync: false });
|
||||
}
|
||||
|
||||
async function getSessionsByUser(user) {
|
||||
return knex("ws_sessions").where({ user });
|
||||
}
|
||||
|
||||
async function deleteSession(session_id, device_id) {
|
||||
await knex("ws_sessions").where({ session_id, device_id }).del();
|
||||
await knex("pending_events").where({ session_id, device_id }).del();
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// PENDING EVENTS (nuovo modello)
|
||||
// ===============================
|
||||
|
||||
async function insertPendingEvent({ user_id, session_id, device_id, event_id, payload, created_at }) {
|
||||
return knex("pending_events").insert({
|
||||
user_id,
|
||||
session_id,
|
||||
device_id,
|
||||
event_id,
|
||||
payload,
|
||||
created_at
|
||||
});
|
||||
}
|
||||
|
||||
async function deletePendingEvent({ user_id, session_id, device_id, event_id }) {
|
||||
return knex("pending_events")
|
||||
.where({ user_id, session_id, device_id, event_id })
|
||||
.delete();
|
||||
}
|
||||
|
||||
async function getPendingEvents(user_id, session_id, device_id) {
|
||||
return knex("pending_events")
|
||||
.where({ user_id, session_id, device_id })
|
||||
.orderBy("created_at", "asc");
|
||||
}
|
||||
|
||||
async function deleteAllSessions() {
|
||||
console.log(">>> deleteAllSessions() – elimino tutte le sessioni WS");
|
||||
return knex("ws_sessions").del();
|
||||
}
|
||||
|
||||
async function deleteAllPendingEvents() {
|
||||
console.log(">>> deleteAllPendingEvents() – elimino tutti i pending events");
|
||||
return knex("pending_events").del();
|
||||
}
|
||||
|
||||
async function setLastSync(session_id, device_id, timestamp = null) {
|
||||
const ts = timestamp || Date.now();
|
||||
return knex("ws_sessions")
|
||||
.where({ session_id, device_id })
|
||||
.update({ last_sync: ts });
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// EXPORT
|
||||
// ===============================
|
||||
|
||||
module.exports = {
|
||||
upsertSession,
|
||||
getSession,
|
||||
updateSessionAck,
|
||||
setSessionNeedFullSync,
|
||||
clearNeedFullSync,
|
||||
deleteSession,
|
||||
getSessionsByUser,
|
||||
|
||||
insertPendingEvent,
|
||||
deletePendingEvent,
|
||||
getPendingEvents,
|
||||
getDevicesForSession,
|
||||
|
||||
setLastSync,
|
||||
deleteAllSessions,
|
||||
deleteAllPendingEvents
|
||||
};
|
||||
219
db/faceScanAutomation.js
Normal file
219
db/faceScanAutomation.js
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
async function ensureFaceScanAutomation(db) {
|
||||
const triggerNames = [
|
||||
'photos_face_enqueue_insert',
|
||||
'photos_face_enqueue_update',
|
||||
'photos_face_cleanup_update',
|
||||
'photos_face_cleanup_delete',
|
||||
];
|
||||
|
||||
for (const triggerName of triggerNames) {
|
||||
await db.raw(
|
||||
`DROP TRIGGER IF EXISTS ${triggerName}`,
|
||||
);
|
||||
}
|
||||
|
||||
await db.raw(`
|
||||
CREATE TRIGGER photos_face_enqueue_insert
|
||||
AFTER INSERT ON photos
|
||||
WHEN
|
||||
NEW.deleted_at IS NULL
|
||||
AND NEW.path IS NOT NULL
|
||||
AND NEW.path != ''
|
||||
AND (
|
||||
NEW.mime_type IS NULL
|
||||
OR NEW.mime_type LIKE 'image/%'
|
||||
)
|
||||
BEGIN
|
||||
INSERT INTO face_scan_jobs (
|
||||
photo_id,
|
||||
status,
|
||||
attempts,
|
||||
rescan_requested,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (
|
||||
NEW.id,
|
||||
'pending',
|
||||
0,
|
||||
0,
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
)
|
||||
ON CONFLICT(photo_id) DO UPDATE SET
|
||||
status = CASE
|
||||
WHEN face_scan_jobs.status = 'processing'
|
||||
THEN face_scan_jobs.status
|
||||
ELSE 'pending'
|
||||
END,
|
||||
|
||||
rescan_requested = CASE
|
||||
WHEN face_scan_jobs.status = 'processing'
|
||||
THEN 1
|
||||
ELSE 0
|
||||
END,
|
||||
|
||||
faces_detected = CASE
|
||||
WHEN face_scan_jobs.status = 'processing'
|
||||
THEN face_scan_jobs.faces_detected
|
||||
ELSE NULL
|
||||
END,
|
||||
|
||||
execution_ms = CASE
|
||||
WHEN face_scan_jobs.status = 'processing'
|
||||
THEN face_scan_jobs.execution_ms
|
||||
ELSE NULL
|
||||
END,
|
||||
|
||||
applied_rotation = CASE
|
||||
WHEN face_scan_jobs.status = 'processing'
|
||||
THEN face_scan_jobs.applied_rotation
|
||||
ELSE NULL
|
||||
END,
|
||||
|
||||
last_error = NULL,
|
||||
|
||||
started_at = CASE
|
||||
WHEN face_scan_jobs.status = 'processing'
|
||||
THEN face_scan_jobs.started_at
|
||||
ELSE NULL
|
||||
END,
|
||||
|
||||
completed_at = NULL,
|
||||
updated_at = CURRENT_TIMESTAMP;
|
||||
END
|
||||
`);
|
||||
|
||||
await db.raw(`
|
||||
CREATE TRIGGER photos_face_enqueue_update
|
||||
AFTER UPDATE OF
|
||||
path,
|
||||
fast_hash,
|
||||
mtimeMs,
|
||||
size_bytes,
|
||||
rotation,
|
||||
mime_type,
|
||||
deleted_at
|
||||
ON photos
|
||||
WHEN
|
||||
NEW.deleted_at IS NULL
|
||||
AND NEW.path IS NOT NULL
|
||||
AND NEW.path != ''
|
||||
AND (
|
||||
NEW.mime_type IS NULL
|
||||
OR NEW.mime_type LIKE 'image/%'
|
||||
)
|
||||
AND (
|
||||
OLD.path IS NOT NEW.path
|
||||
OR OLD.fast_hash IS NOT NEW.fast_hash
|
||||
OR OLD.mtimeMs IS NOT NEW.mtimeMs
|
||||
OR OLD.size_bytes IS NOT NEW.size_bytes
|
||||
OR OLD.rotation IS NOT NEW.rotation
|
||||
OR OLD.mime_type IS NOT NEW.mime_type
|
||||
OR OLD.deleted_at IS NOT NEW.deleted_at
|
||||
)
|
||||
BEGIN
|
||||
INSERT INTO face_scan_jobs (
|
||||
photo_id,
|
||||
status,
|
||||
attempts,
|
||||
rescan_requested,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (
|
||||
NEW.id,
|
||||
'pending',
|
||||
0,
|
||||
0,
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
)
|
||||
ON CONFLICT(photo_id) DO UPDATE SET
|
||||
status = CASE
|
||||
WHEN face_scan_jobs.status = 'processing'
|
||||
THEN face_scan_jobs.status
|
||||
ELSE 'pending'
|
||||
END,
|
||||
|
||||
rescan_requested = CASE
|
||||
WHEN face_scan_jobs.status = 'processing'
|
||||
THEN 1
|
||||
ELSE 0
|
||||
END,
|
||||
|
||||
faces_detected = CASE
|
||||
WHEN face_scan_jobs.status = 'processing'
|
||||
THEN face_scan_jobs.faces_detected
|
||||
ELSE NULL
|
||||
END,
|
||||
|
||||
execution_ms = CASE
|
||||
WHEN face_scan_jobs.status = 'processing'
|
||||
THEN face_scan_jobs.execution_ms
|
||||
ELSE NULL
|
||||
END,
|
||||
|
||||
applied_rotation = CASE
|
||||
WHEN face_scan_jobs.status = 'processing'
|
||||
THEN face_scan_jobs.applied_rotation
|
||||
ELSE NULL
|
||||
END,
|
||||
|
||||
last_error = NULL,
|
||||
|
||||
started_at = CASE
|
||||
WHEN face_scan_jobs.status = 'processing'
|
||||
THEN face_scan_jobs.started_at
|
||||
ELSE NULL
|
||||
END,
|
||||
|
||||
completed_at = NULL,
|
||||
updated_at = CURRENT_TIMESTAMP;
|
||||
END
|
||||
`);
|
||||
|
||||
await db.raw(`
|
||||
CREATE TRIGGER photos_face_cleanup_update
|
||||
AFTER UPDATE OF
|
||||
path,
|
||||
mime_type,
|
||||
deleted_at
|
||||
ON photos
|
||||
WHEN
|
||||
NEW.deleted_at IS NOT NULL
|
||||
OR NEW.path IS NULL
|
||||
OR NEW.path = ''
|
||||
OR (
|
||||
NEW.mime_type IS NOT NULL
|
||||
AND NEW.mime_type NOT LIKE 'image/%'
|
||||
)
|
||||
BEGIN
|
||||
DELETE FROM faces
|
||||
WHERE photo_id = NEW.id;
|
||||
|
||||
DELETE FROM face_scan_jobs
|
||||
WHERE photo_id = NEW.id;
|
||||
END
|
||||
`);
|
||||
|
||||
await db.raw(`
|
||||
CREATE TRIGGER photos_face_cleanup_delete
|
||||
AFTER DELETE ON photos
|
||||
BEGIN
|
||||
DELETE FROM faces
|
||||
WHERE photo_id = OLD.id;
|
||||
|
||||
DELETE FROM face_scan_jobs
|
||||
WHERE photo_id = OLD.id;
|
||||
END
|
||||
`);
|
||||
|
||||
console.log(
|
||||
'✔ trigger automatici scansione volti creati',
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ensureFaceScanAutomation,
|
||||
};
|
||||
547
db/init.j
Normal file
547
db/init.j
Normal file
|
|
@ -0,0 +1,547 @@
|
|||
// db/init.js
|
||||
const db = require('./knex');
|
||||
|
||||
/* ============================================================
|
||||
FOTO
|
||||
============================================================ */
|
||||
async function ensurePhotos() {
|
||||
const exists = await db.schema.hasTable('photos');
|
||||
if (!exists) {
|
||||
await db.schema.createTable('photos', (t) => {
|
||||
t.string('id').primary();
|
||||
t.string('user');
|
||||
t.string('cartella');
|
||||
t.string('name');
|
||||
t.string('path');
|
||||
t.text('extPath').notNullable().defaultTo('');
|
||||
t.text('extMedia').notNullable().defaultTo('');
|
||||
t.integer('hybrid').notNullable().defaultTo(0);
|
||||
t.string('thub1');
|
||||
t.string('thub2');
|
||||
t.string('cast');
|
||||
t.string('mime_type');
|
||||
t.integer('width');
|
||||
t.integer('height');
|
||||
t.integer('rotation');
|
||||
t.integer('size_bytes');
|
||||
t.integer('mtimeMs');
|
||||
t.integer('duration_ms');
|
||||
t.string('taken_at');
|
||||
t.string('data');
|
||||
t.float('lat');
|
||||
t.float('lon');
|
||||
t.float('alt');
|
||||
t.text('location');
|
||||
t.string('_indexHash');
|
||||
t.string('fast_hash');
|
||||
t.datetime('created_at').defaultTo(db.fn.now());
|
||||
t.datetime('updated_at').defaultTo(db.fn.now());
|
||||
t.datetime('deleted_at').nullable();
|
||||
});
|
||||
console.log("✔ photos creata");
|
||||
} else {
|
||||
const cols = [
|
||||
'thub1','thub2','fast_hash','created_at','updated_at','deleted_at'
|
||||
];
|
||||
for (const col of cols) {
|
||||
const existsCol = await db.schema.hasColumn('photos', col);
|
||||
if (!existsCol) {
|
||||
await db.schema.alterTable('photos', (t) => {
|
||||
if (col === 'created_at' || col === 'updated_at')
|
||||
t.datetime(col).defaultTo(db.fn.now());
|
||||
else if (col === 'deleted_at')
|
||||
t.datetime(col).nullable();
|
||||
else
|
||||
t.string(col);
|
||||
});
|
||||
console.log(`✔ aggiunta colonna photos.${col}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
FOTO — deleted_hard
|
||||
============================================================ */
|
||||
async function ensureDeletedHard() {
|
||||
const exists = await db.schema.hasTable('deleted_hard');
|
||||
if (!exists) {
|
||||
await db.schema.createTable('deleted_hard', (t) => {
|
||||
t.string('id').primary();
|
||||
t.string('user');
|
||||
t.datetime('deleted_at').defaultTo(db.fn.now());
|
||||
});
|
||||
console.log("✔ deleted_hard creata");
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
WS
|
||||
============================================================ */
|
||||
async function ensureWsSessions() {
|
||||
const exists = await db.schema.hasTable('ws_sessions');
|
||||
if (!exists) {
|
||||
await db.schema.createTable('ws_sessions', (t) => {
|
||||
t.string('session_id').notNullable();
|
||||
t.string('device_id').notNullable();
|
||||
t.string('user').notNullable();
|
||||
t.integer('connected_at').notNullable();
|
||||
t.integer('last_ack').notNullable();
|
||||
t.integer('last_sync').nullable();
|
||||
t.boolean('need_full_sync').notNullable().defaultTo(false);
|
||||
|
||||
t.primary(['session_id', 'device_id']);
|
||||
});
|
||||
console.log("✔ ws_sessions creata");
|
||||
} else {
|
||||
const cols = [
|
||||
{ name: 'device_id', type: 'string' },
|
||||
{ name: 'last_sync', type: 'integer' }
|
||||
];
|
||||
|
||||
for (const c of cols) {
|
||||
const existsCol = await db.schema.hasColumn('ws_sessions', c.name);
|
||||
if (!existsCol) {
|
||||
await db.schema.alterTable('ws_sessions', (t) => {
|
||||
if (c.type === 'string') t.string(c.name).notNullable().defaultTo('default');
|
||||
if (c.type === 'integer') t.integer(c.name).nullable();
|
||||
});
|
||||
console.log(`✔ aggiunta colonna ws_sessions.${c.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureWsPending() {
|
||||
const exists = await db.schema.hasTable('ws_pending_events');
|
||||
if (!exists) {
|
||||
await db.schema.createTable('ws_pending_events', (t) => {
|
||||
t.string('event_id').primary();
|
||||
t.string('session_id').notNullable();
|
||||
t.text('payload').notNullable();
|
||||
t.integer('sent_at').notNullable();
|
||||
t.integer('retries').notNullable().defaultTo(0);
|
||||
});
|
||||
console.log("✔ ws_pending_events creata");
|
||||
}
|
||||
}
|
||||
|
||||
async function ensurePendingEvents() {
|
||||
const exists = await db.schema.hasTable('pending_events');
|
||||
if (!exists) {
|
||||
await db.schema.createTable('pending_events', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.string('user_id').notNullable();
|
||||
t.string('session_id').notNullable();
|
||||
t.string('device_id').notNullable();
|
||||
t.string('event_id').notNullable();
|
||||
t.text('payload').notNullable();
|
||||
t.integer('created_at').notNullable();
|
||||
|
||||
t.unique(['user_id', 'session_id', 'device_id', 'event_id']);
|
||||
});
|
||||
console.log("✔ pending_events creata");
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
🏃 ATTIVITÀ — nuova tabella
|
||||
============================================================ */
|
||||
async function ensureActivities() {
|
||||
const exists = await db.schema.hasTable('activities');
|
||||
if (!exists) {
|
||||
await db.schema.createTable('activities', (t) => {
|
||||
t.string('id').primary();
|
||||
t.string('user');
|
||||
t.string('cartella');
|
||||
|
||||
t.string('file_name');
|
||||
t.string('file_extension');
|
||||
t.string('format');
|
||||
t.string('display_name');
|
||||
t.string('route_name');
|
||||
t.string('title');
|
||||
t.string('description');
|
||||
t.string('notes');
|
||||
t.string('sport');
|
||||
t.string('activity_type');
|
||||
t.string('device_manufacturer');
|
||||
t.string('device_model');
|
||||
t.string('source_app');
|
||||
|
||||
t.bigInteger('start_time_millis');
|
||||
t.bigInteger('end_time_millis');
|
||||
t.bigInteger('duration_millis');
|
||||
|
||||
t.float('distance_meters');
|
||||
t.float('elevation_gain_meters');
|
||||
t.float('elevation_loss_meters');
|
||||
|
||||
t.float('min_elevation_meters');
|
||||
t.float('max_elevation_meters');
|
||||
|
||||
t.float('file_distance_meters');
|
||||
t.float('file_elevation_gain_meters');
|
||||
|
||||
t.float('computed_distance_meters');
|
||||
t.float('computed_elevation_gain_meters');
|
||||
|
||||
t.float('average_speed_mps');
|
||||
t.float('max_speed_mps');
|
||||
t.float('average_pace_sec_per_km');
|
||||
|
||||
t.integer('average_heart_rate');
|
||||
t.integer('max_heart_rate');
|
||||
t.integer('average_cadence');
|
||||
t.integer('max_cadence');
|
||||
|
||||
t.integer('calories');
|
||||
t.integer('power_avg');
|
||||
t.integer('power_max');
|
||||
|
||||
t.integer('point_count');
|
||||
t.integer('segment_count');
|
||||
t.integer('lap_count');
|
||||
|
||||
t.float('min_latitude');
|
||||
t.float('max_latitude');
|
||||
t.float('min_longitude');
|
||||
t.float('max_longitude');
|
||||
t.float('center_latitude');
|
||||
t.float('center_longitude');
|
||||
|
||||
t.bigInteger('size_bytes');
|
||||
t.bigInteger('last_modified_millis');
|
||||
t.bigInteger('date_added_millis');
|
||||
t.bigInteger('date_modified_millis');
|
||||
t.bigInteger('last_scan_millis');
|
||||
|
||||
t.boolean('is_favorite');
|
||||
t.boolean('is_hidden');
|
||||
t.integer('rating');
|
||||
|
||||
t.text('tags');
|
||||
t.text('track_preview');
|
||||
t.string('scan_status');
|
||||
t.text('scan_error');
|
||||
t.string('path');
|
||||
t.text('extPath').notNullable().defaultTo('');
|
||||
t.integer('hybrid').notNullable().defaultTo(0);
|
||||
t.text('diagnostics');
|
||||
t.text('location');
|
||||
t.string('fast_hash');
|
||||
t.datetime('deleted_at').nullable();
|
||||
t.datetime('created_at').defaultTo(db.fn.now());
|
||||
t.datetime('updated_at').defaultTo(db.fn.now());
|
||||
});
|
||||
console.log("✔ activities creata");
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
ATTIVITÀ — deleted_hard
|
||||
============================================================ */
|
||||
async function ensureDeletedHardActivities() {
|
||||
const exists = await db.schema.hasTable('deleted_hard_activities');
|
||||
if (!exists) {
|
||||
await db.schema.createTable('deleted_hard_activities', (t) => {
|
||||
t.string('id').primary();
|
||||
t.string('user');
|
||||
t.datetime('deleted_at').defaultTo(db.fn.now());
|
||||
});
|
||||
console.log("✔ deleted_hard_activities creata");
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
ATTIVITÀ — sync_done
|
||||
============================================================ */
|
||||
async function ensureSyncDoneActivities() {
|
||||
const exists = await db.schema.hasTable('sync_done_activities');
|
||||
if (!exists) {
|
||||
await db.schema.createTable('sync_done_activities', (t) => {
|
||||
t.string('session_id');
|
||||
t.string('device_id');
|
||||
t.bigInteger('last_sync');
|
||||
});
|
||||
console.log("✔ sync_done_activities creata");
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
VOLTI
|
||||
============================================================ */
|
||||
async function ensureFaces() {
|
||||
const exists = await db.schema.hasTable('faces');
|
||||
|
||||
if (!exists) {
|
||||
await db.schema.createTable('faces', (t) => {
|
||||
// ID univoco del volto
|
||||
t.string('id').primary();
|
||||
|
||||
// Foto da cui è stato estratto
|
||||
t.string('photo_id').notNullable();
|
||||
t.integer('face_index').notNullable();
|
||||
|
||||
// Bounding box nel frame originale
|
||||
t.float('x').notNullable();
|
||||
t.float('y').notNullable();
|
||||
t.float('width').notNullable();
|
||||
t.float('height').notNullable();
|
||||
|
||||
// Confidence del detector SCRFD
|
||||
t.float('score').notNullable();
|
||||
|
||||
// 5 landmark × 2 coordinate
|
||||
t.float('landmark_0_x');
|
||||
t.float('landmark_0_y');
|
||||
|
||||
t.float('landmark_1_x');
|
||||
t.float('landmark_1_y');
|
||||
|
||||
t.float('landmark_2_x');
|
||||
t.float('landmark_2_y');
|
||||
|
||||
t.float('landmark_3_x');
|
||||
t.float('landmark_3_y');
|
||||
|
||||
t.float('landmark_4_x');
|
||||
t.float('landmark_4_y');
|
||||
|
||||
// 128 × float32 = 512 byte
|
||||
t.binary('embedding').notNullable();
|
||||
|
||||
// Risultato HDBSCAN.
|
||||
// NULL = non ancora clusterizzato.
|
||||
t.string('cluster_id').nullable();
|
||||
|
||||
// Persona assegnata dall'utente.
|
||||
// NULL = identità non ancora assegnata.
|
||||
t.string('person_id').nullable();
|
||||
|
||||
// Stato applicativo del volto.
|
||||
// detected / clustered / assigned
|
||||
t.string('status').notNullable().defaultTo('detected');
|
||||
|
||||
t.datetime('created_at').defaultTo(db.fn.now());
|
||||
t.datetime('updated_at').defaultTo(db.fn.now());
|
||||
|
||||
// Indici principali
|
||||
t.index(['photo_id']);
|
||||
t.index(['cluster_id']);
|
||||
t.index(['person_id']);
|
||||
t.index(['status']);
|
||||
|
||||
t.unique(['photo_id', 'face_index']);
|
||||
// Evita due record per lo stesso volto nella stessa foto
|
||||
// solo se il nostro scanner assegnerà un face_index.
|
||||
});
|
||||
|
||||
console.log("✔ faces creata");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
CODA SCANSIONE VOLTI
|
||||
============================================================ */
|
||||
async function ensureFaceScanJobs() {
|
||||
const exists =
|
||||
await db.schema.hasTable('face_scan_jobs');
|
||||
|
||||
if (!exists) {
|
||||
await db.schema.createTable(
|
||||
'face_scan_jobs',
|
||||
(t) => {
|
||||
t.string('photo_id').primary();
|
||||
|
||||
// pending / processing / done / error
|
||||
t.string('status')
|
||||
.notNullable()
|
||||
.defaultTo('pending');
|
||||
|
||||
t.integer('attempts')
|
||||
.notNullable()
|
||||
.defaultTo(0);
|
||||
|
||||
t.integer('rescan_requested')
|
||||
.notNullable()
|
||||
.defaultTo(0);
|
||||
|
||||
t.integer('faces_detected').nullable();
|
||||
t.integer('execution_ms').nullable();
|
||||
t.integer('applied_rotation').nullable();
|
||||
|
||||
t.text('last_error').nullable();
|
||||
|
||||
t.datetime('started_at').nullable();
|
||||
t.datetime('completed_at').nullable();
|
||||
|
||||
t.datetime('created_at')
|
||||
.defaultTo(db.fn.now());
|
||||
|
||||
t.datetime('updated_at')
|
||||
.defaultTo(db.fn.now());
|
||||
|
||||
t.index(['status']);
|
||||
t.index(['updated_at']);
|
||||
},
|
||||
);
|
||||
|
||||
console.log("✔ face_scan_jobs creata");
|
||||
} else {
|
||||
const hasRescanRequested =
|
||||
await db.schema.hasColumn(
|
||||
'face_scan_jobs',
|
||||
'rescan_requested',
|
||||
);
|
||||
|
||||
if (!hasRescanRequested) {
|
||||
await db.schema.alterTable(
|
||||
'face_scan_jobs',
|
||||
(t) => {
|
||||
t.integer('rescan_requested')
|
||||
.notNullable()
|
||||
.defaultTo(0);
|
||||
},
|
||||
);
|
||||
|
||||
console.log(
|
||||
"✔ aggiunta face_scan_jobs.rescan_requested",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ============================================================
|
||||
CLUSTER HDBSCAN
|
||||
============================================================ */
|
||||
async function ensureFaceClusters() {
|
||||
const exists = await db.schema.hasTable('face_clusters');
|
||||
|
||||
if (!exists) {
|
||||
await db.schema.createTable('face_clusters', (t) => {
|
||||
// ID persistente del cluster
|
||||
t.string('id').primary();
|
||||
|
||||
// Identifica una specifica esecuzione di HDBSCAN.
|
||||
// Esempio: "initial-2026-09-23T04:20:00"
|
||||
t.string('run_id').notNullable();
|
||||
|
||||
// Label numerica prodotta da HDBSCAN.
|
||||
// Può essere -1 per noise.
|
||||
t.integer('label').notNullable();
|
||||
|
||||
// Numero di facce nel cluster
|
||||
t.integer('size').notNullable().defaultTo(0);
|
||||
|
||||
// Parametri principali usati per generare il cluster
|
||||
t.integer('min_samples');
|
||||
t.integer('min_cluster_size');
|
||||
|
||||
// NULL se il cluster non è ancora stato
|
||||
// associato ad una persona.
|
||||
t.string('person_id').nullable();
|
||||
|
||||
t.datetime('created_at').defaultTo(db.fn.now());
|
||||
t.datetime('updated_at').defaultTo(db.fn.now());
|
||||
|
||||
t.index(['run_id']);
|
||||
t.index(['person_id']);
|
||||
|
||||
// Un label può comparire una sola volta
|
||||
// all'interno dello stesso run.
|
||||
t.unique(['run_id', 'label']);
|
||||
});
|
||||
|
||||
console.log("✔ face_clusters creata");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
PERSONE
|
||||
============================================================ */
|
||||
async function ensurePersons() {
|
||||
const exists = await db.schema.hasTable('persons');
|
||||
|
||||
if (!exists) {
|
||||
await db.schema.createTable('persons', (t) => {
|
||||
// ID stabile dell'identità applicativa
|
||||
t.string('id').primary();
|
||||
|
||||
// Utente proprietario della libreria
|
||||
t.string('user').nullable();
|
||||
|
||||
// Nome assegnato dall'utente
|
||||
t.string('name').notNullable();
|
||||
|
||||
t.datetime('created_at').defaultTo(db.fn.now());
|
||||
t.datetime('updated_at').defaultTo(db.fn.now());
|
||||
|
||||
t.index(['user']);
|
||||
t.index(['name']);
|
||||
});
|
||||
|
||||
console.log("✔ persons creata");
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
INIT
|
||||
============================================================ */
|
||||
/* async function init() {
|
||||
try {
|
||||
console.log("🔧 init DB...");
|
||||
await ensurePhotos();
|
||||
await ensureDeletedHard();
|
||||
|
||||
await ensureActivities();
|
||||
await ensureDeletedHardActivities();
|
||||
await ensureSyncDoneActivities();
|
||||
|
||||
await ensureWsSessions();
|
||||
await ensureWsPending();
|
||||
await ensurePendingEvents();
|
||||
|
||||
console.log("✅ init completato");
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error("❌ errore init:", err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
*/
|
||||
async function init() {
|
||||
try {
|
||||
console.log("🔧 init DB...");
|
||||
|
||||
await ensurePhotos();
|
||||
await ensureDeletedHard();
|
||||
|
||||
await ensureActivities();
|
||||
await ensureDeletedHardActivities();
|
||||
await ensureSyncDoneActivities();
|
||||
|
||||
await ensureWsSessions();
|
||||
await ensureWsPending();
|
||||
await ensurePendingEvents();
|
||||
|
||||
// Face recognition
|
||||
await ensureFaces();
|
||||
await ensureFaceScanJobs();
|
||||
await ensureFaceClusters();
|
||||
await ensurePersons();
|
||||
|
||||
console.log("✅ init completato");
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error("❌ errore init:", err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
init();
|
||||
1325
db/init.js
Normal file
1325
db/init.js
Normal file
File diff suppressed because it is too large
Load diff
36
db/knex.js
Normal file
36
db/knex.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// db/knex.js
|
||||
const knex = require('knex');
|
||||
const path = require('path');
|
||||
|
||||
const db = knex({
|
||||
client: 'better-sqlite3',
|
||||
|
||||
connection: {
|
||||
filename: './api_v1/database.sqlite',
|
||||
},
|
||||
|
||||
useNullAsDefault: true,
|
||||
|
||||
pool: {
|
||||
afterCreate(conn, done) {
|
||||
try {
|
||||
conn.pragma('journal_mode = WAL');
|
||||
conn.pragma('busy_timeout = 30000');
|
||||
|
||||
console.log('[DB] journal_mode=WAL');
|
||||
console.log('[DB] busy_timeout=30000');
|
||||
} catch (err) {
|
||||
console.error('[DB] pragma error', err);
|
||||
}
|
||||
|
||||
done(null, conn);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
'📌 DB PATH:',
|
||||
path.resolve('./api_v1/database.sqlite')
|
||||
);
|
||||
|
||||
module.exports = db;
|
||||
73
db/photos.js
Normal file
73
db/photos.js
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
// db/photos.js
|
||||
const knex = require('./knex');
|
||||
|
||||
async function insertOrUpdatePhoto(row) {
|
||||
// row deve contenere almeno id, user, cartella, name, path, thumb1, thumb2, fast_hash, mtimeMs, etc.
|
||||
const exists = await knex('photos').where({ id: row.id }).first();
|
||||
if (!exists) {
|
||||
await knex('photos').insert({
|
||||
id: row.id,
|
||||
user: row.user,
|
||||
cartella: row.cartella,
|
||||
name: row.name,
|
||||
path: row.path,
|
||||
thumb1: row.thumb1 || null,
|
||||
thumb2: row.thumb2 || null,
|
||||
mime_type: row.mime_type || null,
|
||||
width: row.width || null,
|
||||
height: row.height || null,
|
||||
rotation: row.rotation || null,
|
||||
size_bytes: row.size_bytes || null,
|
||||
mtimeMs: row.mtimeMs || null,
|
||||
taken_at: row.taken_at || null,
|
||||
fast_hash: row.fast_hash || null,
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now()
|
||||
});
|
||||
} else {
|
||||
await knex('photos').where({ id: row.id }).update({
|
||||
path: row.path,
|
||||
thumb1: row.thumb1 || exists.thumb1,
|
||||
thumb2: row.thumb2 || exists.thumb2,
|
||||
mime_type: row.mime_type || exists.mime_type,
|
||||
width: row.width || exists.width,
|
||||
height: row.height || exists.height,
|
||||
rotation: row.rotation || exists.rotation,
|
||||
size_bytes: row.size_bytes || exists.size_bytes,
|
||||
mtimeMs: row.mtimeMs || exists.mtimeMs,
|
||||
taken_at: row.taken_at || exists.taken_at,
|
||||
fast_hash: row.fast_hash || exists.fast_hash,
|
||||
updated_at: knex.fn.now(),
|
||||
deleted_at: null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function markPhotoDeletedHard(id, user) {
|
||||
await knex('photos').where({ id, user }).del();
|
||||
await knex('deleted_hard').insert({ id, user, deleted_at: knex.fn.now() }).catch(() => {});
|
||||
}
|
||||
|
||||
async function getPhotosSince(user, sinceIso, limit = 1000) {
|
||||
// ritorna foto aggiornate dopo sinceIso (ISO string)
|
||||
return knex('photos')
|
||||
.where({ user })
|
||||
.andWhere('updated_at', '>', sinceIso)
|
||||
.orderBy('updated_at', 'asc')
|
||||
.limit(limit)
|
||||
.select('*');
|
||||
}
|
||||
|
||||
async function getDeletedHardSince(user, sinceIso) {
|
||||
return knex('deleted_hard')
|
||||
.where({ user })
|
||||
.andWhere('deleted_at', '>', sinceIso)
|
||||
.select('id', 'deleted_at');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
insertOrUpdatePhoto,
|
||||
markPhotoDeletedHard,
|
||||
getPhotosSince,
|
||||
getDeletedHardSince
|
||||
};
|
||||
147
db/serverEventOutbox.js
Normal file
147
db/serverEventOutbox.js
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
'use strict';
|
||||
|
||||
/*
|
||||
* Outbox inter-processo.
|
||||
*
|
||||
* I daemon possono produrre eventi senza importare ws-server.
|
||||
* Solo il processo server consuma questi eventi e li inoltra
|
||||
* al WebSocket reale.
|
||||
*/
|
||||
|
||||
let ensurePromise = null;
|
||||
|
||||
async function ensureServerEventOutbox(db) {
|
||||
if (!ensurePromise) {
|
||||
ensurePromise = (async () => {
|
||||
const exists = await db.schema.hasTable('server_event_outbox');
|
||||
|
||||
if (!exists) {
|
||||
await db.schema.createTable('server_event_outbox', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.string('channel').notNullable();
|
||||
t.string('user').notNullable();
|
||||
t.text('payload').notNullable();
|
||||
|
||||
t.string('status')
|
||||
.notNullable()
|
||||
.defaultTo('pending');
|
||||
|
||||
t.integer('attempts')
|
||||
.notNullable()
|
||||
.defaultTo(0);
|
||||
|
||||
t.text('last_error').nullable();
|
||||
|
||||
t.datetime('created_at')
|
||||
.notNullable()
|
||||
.defaultTo(db.fn.now());
|
||||
|
||||
t.datetime('updated_at')
|
||||
.notNullable()
|
||||
.defaultTo(db.fn.now());
|
||||
|
||||
t.datetime('processed_at').nullable();
|
||||
|
||||
t.index(
|
||||
['status', 'id'],
|
||||
'server_event_outbox_status_id_idx'
|
||||
);
|
||||
});
|
||||
|
||||
console.log(
|
||||
'[ServerEventOutbox] tabella server_event_outbox creata'
|
||||
);
|
||||
}
|
||||
})().catch((err) => {
|
||||
ensurePromise = null;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
return ensurePromise;
|
||||
}
|
||||
|
||||
async function enqueueServerEvent(
|
||||
db,
|
||||
{
|
||||
channel,
|
||||
user,
|
||||
payload,
|
||||
}
|
||||
) {
|
||||
if (!channel || !user || !payload) {
|
||||
throw new Error(
|
||||
'enqueueServerEvent richiede channel, user e payload'
|
||||
);
|
||||
}
|
||||
|
||||
await ensureServerEventOutbox(db);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
await db('server_event_outbox').insert({
|
||||
channel,
|
||||
user,
|
||||
payload: JSON.stringify(payload),
|
||||
status: 'pending',
|
||||
attempts: 0,
|
||||
last_error: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
processed_at: null,
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* C'e' un solo consumer nel processo server.
|
||||
* Non serve tenere il job in processing durante il broadcast:
|
||||
* viene selezionato pending e marcato done soltanto dopo
|
||||
* un broadcast completato senza eccezioni.
|
||||
*/
|
||||
async function getNextPendingServerEvent(db) {
|
||||
await ensureServerEventOutbox(db);
|
||||
|
||||
return db('server_event_outbox')
|
||||
.where({ status: 'pending' })
|
||||
.orderBy('id', 'asc')
|
||||
.first();
|
||||
}
|
||||
|
||||
async function completeServerEvent(db, id) {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
return db('server_event_outbox')
|
||||
.where({
|
||||
id,
|
||||
status: 'pending',
|
||||
})
|
||||
.update({
|
||||
status: 'done',
|
||||
processed_at: now,
|
||||
updated_at: now,
|
||||
last_error: null,
|
||||
});
|
||||
}
|
||||
|
||||
async function failServerEvent(db, id, error) {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
return db('server_event_outbox')
|
||||
.where({
|
||||
id,
|
||||
status: 'pending',
|
||||
})
|
||||
.update({
|
||||
attempts: db.raw('attempts + 1'),
|
||||
last_error: String(error || 'Errore sconosciuto'),
|
||||
updated_at: now,
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ensureServerEventOutbox,
|
||||
enqueueServerEvent,
|
||||
getNextPendingServerEvent,
|
||||
completeServerEvent,
|
||||
failServerEvent,
|
||||
};
|
||||
68
db/sqliteRetry.js
Normal file
68
db/sqliteRetry.js
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
'use strict';
|
||||
|
||||
const SQLITE_BUSY_RETRY_DELAYS_MS = [
|
||||
25,
|
||||
50,
|
||||
100,
|
||||
200,
|
||||
400,
|
||||
800,
|
||||
1600,
|
||||
];
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function isSqliteBusy(error) {
|
||||
if (!error) return false;
|
||||
|
||||
if (
|
||||
error.code === 'SQLITE_BUSY' ||
|
||||
error.code === 'SQLITE_LOCKED'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const message = String(
|
||||
error.message || error
|
||||
).toLowerCase();
|
||||
|
||||
return (
|
||||
message.includes('database is locked') ||
|
||||
message.includes('database table is locked')
|
||||
);
|
||||
}
|
||||
|
||||
async function withSqliteBusyRetry(label, operation) {
|
||||
let retry = 0;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
if (
|
||||
!isSqliteBusy(error) ||
|
||||
retry >= SQLITE_BUSY_RETRY_DELAYS_MS.length
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const delay = SQLITE_BUSY_RETRY_DELAYS_MS[retry];
|
||||
retry += 1;
|
||||
|
||||
console.warn(
|
||||
`[SQLiteRetry] SQLITE_BUSY ${label}; ` +
|
||||
`retry=${retry}/${SQLITE_BUSY_RETRY_DELAYS_MS.length} ` +
|
||||
`delay=${delay}ms`
|
||||
);
|
||||
|
||||
await sleep(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isSqliteBusy,
|
||||
withSqliteBusyRetry,
|
||||
};
|
||||
86
diff_js_css_html.py
Normal file
86
diff_js_css_html.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from pathlib import Path
|
||||
import hashlib
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Confronta i file JS, CSS e HTML della cartella corrente "
|
||||
"con un'altra cartella specificata come parametro."
|
||||
)
|
||||
parser.add_argument(
|
||||
"altra_cartella",
|
||||
help="Percorso della cartella da confrontare con quella corrente"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
old = Path(".").resolve()
|
||||
new = Path(args.altra_cartella).expanduser().resolve()
|
||||
|
||||
if not old.is_dir():
|
||||
sys.exit(f"ERRORE: cartella corrente non valida: {old}")
|
||||
|
||||
if not new.is_dir():
|
||||
sys.exit(f"ERRORE: cartella inesistente: {new}")
|
||||
|
||||
exts = {".js", ".mjs", ".cjs", ".jsx", ".css", ".html", ".htm"}
|
||||
excluded = {
|
||||
"node_modules", ".git", ".venv", ".venv-clustering",
|
||||
"var", "native", "coverage", "dist", "build"
|
||||
}
|
||||
|
||||
|
||||
def collect(root):
|
||||
result = {}
|
||||
|
||||
for p in root.rglob("*"):
|
||||
if not p.is_file() or p.suffix.lower() not in exts:
|
||||
continue
|
||||
|
||||
rel = p.relative_to(root)
|
||||
|
||||
if any(part in excluded or part.startswith("backup-")
|
||||
for part in rel.parts):
|
||||
continue
|
||||
|
||||
result[str(rel)] = p
|
||||
|
||||
return result
|
||||
|
||||
|
||||
a = collect(old)
|
||||
b = collect(new)
|
||||
|
||||
added = sorted(b.keys() - a.keys())
|
||||
removed = sorted(a.keys() - b.keys())
|
||||
common = sorted(a.keys() & b.keys())
|
||||
|
||||
modified = [
|
||||
f for f in common
|
||||
if hashlib.sha256(a[f].read_bytes()).digest()
|
||||
!= hashlib.sha256(b[f].read_bytes()).digest()
|
||||
]
|
||||
|
||||
print(f"\nCARTELLA CORRENTE: {old}")
|
||||
print(f"ALTRA CARTELLA: {new}")
|
||||
|
||||
print(f"\nAGGIUNTI nella cartella corrente: {len(removed)}")
|
||||
for f in removed:
|
||||
print(" +", f)
|
||||
|
||||
print(f"\nASSENTI dalla cartella corrente: {len(added)}")
|
||||
for f in added:
|
||||
print(" -", f)
|
||||
|
||||
print(f"\nMODIFICATI: {len(modified)}")
|
||||
for f in modified:
|
||||
print(" *", f)
|
||||
|
||||
Path("/tmp/s184js-modificati.txt").write_text(
|
||||
"\n".join(modified) + ("\n" if modified else ""),
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
print("\nElenco dei modificati salvato in /tmp/s184js-modificati.txt")
|
||||
278
docs/README_WS.md
Normal file
278
docs/README_WS.md
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
Perfetto Fabio — ora ti preparo il diagramma del flusso completo, chiaro, leggibile e fedele al comportamento reale del tuo sistema.
|
||||
|
||||
È un diagramma architetturale, non un semplice schema: mostra tutto il ciclo di vita di una sessione WebSocket affidabile, con SQLite, retry, timeout, needfullsync e sync del client.
|
||||
|
||||
---
|
||||
|
||||
🟦 DIAGRAMMA DEL FLUSSO COMPLETO — WebSocket Affidabile + Sync
|
||||
|
||||
Lo divido in 6 sezioni:
|
||||
|
||||
1. Connessione iniziale
|
||||
2. Autenticazione + session_id
|
||||
3. Invio evento dal server
|
||||
4. Ricezione evento dal client
|
||||
5. Retry + Timeout + needfullsync
|
||||
6. Riconnessione del client
|
||||
|
||||
---
|
||||
|
||||
🟩 1) CONNESSIONE INIZIALE
|
||||
|
||||
`
|
||||
CLIENT (browser) SERVER (ws-server.js)
|
||||
──────────────────────────────────────────────────────────────────────
|
||||
|
||||
startWebSocket()
|
||||
↓
|
||||
Apre WebSocket ------------------------→ ws.on("connection")
|
||||
crea ws object
|
||||
ws.authenticated = false
|
||||
`
|
||||
|
||||
---
|
||||
|
||||
🟩 2) AUTENTICAZIONE + SESSION_ID
|
||||
|
||||
`
|
||||
CLIENT SERVER
|
||||
──────────────────────────────────────────────────────────────────────
|
||||
|
||||
Invia:
|
||||
{
|
||||
type: "auth",
|
||||
token: JWT,
|
||||
session_id: <persistente>
|
||||
}
|
||||
↓ ws.on("message")
|
||||
verifica JWT
|
||||
cerca sessione in SQLite
|
||||
se non esiste → createSession()
|
||||
aggiorna last_ack
|
||||
salva wsBySession[session_id] = ws
|
||||
|
||||
Riceve:
|
||||
{
|
||||
type: "auth_ok",
|
||||
user: "...",
|
||||
session_id: "...",
|
||||
needfullsync: true/false
|
||||
}
|
||||
↓
|
||||
Se needfullsync = true → incrementalSync()
|
||||
`
|
||||
|
||||
---
|
||||
|
||||
🟩 3) INVIO EVENTO DAL SERVER (broadcast affidabile)
|
||||
|
||||
`
|
||||
SERVER
|
||||
──────────────────────────────────────────────────────────────────────
|
||||
|
||||
broadcastToUserReliable(user, payload)
|
||||
↓
|
||||
Genera event_id (UUID)
|
||||
↓
|
||||
Per ogni sessione dell’utente:
|
||||
- Se ws attivo:
|
||||
ws.send(payload + event_id)
|
||||
addPendingEvent(eventid, sessionid)
|
||||
- Se ws NON attivo:
|
||||
setSessionNeedFullSync(session_id, true)
|
||||
`
|
||||
|
||||
---
|
||||
|
||||
🟩 4) RICEZIONE EVENTO DAL CLIENT
|
||||
|
||||
`
|
||||
CLIENT
|
||||
──────────────────────────────────────────────────────────────────────
|
||||
|
||||
Riceve evento:
|
||||
{
|
||||
type: "added" | "removed" | ...,
|
||||
id: "...",
|
||||
event_id: "..."
|
||||
}
|
||||
|
||||
↓
|
||||
Se event_id già in processedEvents:
|
||||
- invia solo ACK
|
||||
- ignora evento
|
||||
- FINE
|
||||
|
||||
Se nuovo:
|
||||
- applica evento (add/remove foto)
|
||||
- aggiorna UI
|
||||
- aggiunge event_id a processedEvents
|
||||
- invia ACK:
|
||||
{ type: "ack", event_id }
|
||||
`
|
||||
|
||||
---
|
||||
|
||||
🟩 5) RETRY + TIMEOUT + NEEDFULLSYNC
|
||||
|
||||
`
|
||||
SERVER (ogni 3 secondi)
|
||||
──────────────────────────────────────────────────────────────────────
|
||||
|
||||
Legge da SQLite tutti gli eventi pendenti
|
||||
con sent_at < now - 3000 ms
|
||||
|
||||
Per ogni evento:
|
||||
|
||||
Se retries < 40 (2 minuti):
|
||||
- se ws attivo:
|
||||
ws.send(payload)
|
||||
updatePendingEventRetry()
|
||||
- se ws NON attivo:
|
||||
setSessionNeedFullSync(session_id, true)
|
||||
deletePendingEvent(event_id)
|
||||
|
||||
Se retries >= 40:
|
||||
- ws.terminate()
|
||||
- wsBySession.delete(session_id)
|
||||
- deleteSession(session_id) ← cancella queue
|
||||
- (sessione verrà ricreata al prossimo auth)
|
||||
`
|
||||
|
||||
---
|
||||
|
||||
🟩 6) RICONNESSIONE DEL CLIENT
|
||||
|
||||
`
|
||||
CLIENT riapre la tab / si risveglia
|
||||
──────────────────────────────────────────────────────────────────────
|
||||
|
||||
startWebSocket()
|
||||
↓
|
||||
Invia auth con lo stesso session_id
|
||||
↓
|
||||
SERVER:
|
||||
- trova sessione in SQLite?
|
||||
NO → la ricrea con needfullsync = true
|
||||
SÌ → legge needfullsync
|
||||
|
||||
- invia authok con needfull_sync
|
||||
|
||||
CLIENT:
|
||||
Se needfullsync = true:
|
||||
incrementalSync()
|
||||
Altrimenti:
|
||||
continua con WS realtime
|
||||
`
|
||||
|
||||
---
|
||||
|
||||
🟦 RISULTATO DEL FLUSSO
|
||||
|
||||
✔ Nessun evento perso
|
||||
|
||||
✔ Nessun evento duplicato
|
||||
|
||||
✔ Chrome Android non può più “morire in silenzio”
|
||||
|
||||
✔ Multi‑dispositivo perfetto
|
||||
|
||||
✔ Queue pulita dopo 2 minuti
|
||||
|
||||
✔ Sync completo automatico quando serve
|
||||
|
||||
✔ WS leggero (ping solo on‑demand)
|
||||
|
||||
✔ Stato persistente in SQLite
|
||||
|
||||
---
|
||||
|
||||
|
||||
┌──────────────────────────┐ ┌──────────────────────────┐
|
||||
│ CLIENT │ │ SERVER │
|
||||
└─────────────┬────────────┘ └─────────────┬────────────┘
|
||||
│ │
|
||||
│ 1) Connessione WS │
|
||||
├──────────────────────────────────────→│
|
||||
│ │
|
||||
│ 2) AUTH + session_id │
|
||||
│ {type:auth, token, session_id} │
|
||||
├──────────────────────────────────────→│
|
||||
│ │
|
||||
│ │ verifica JWT
|
||||
│ │ cerca sessione in DB
|
||||
│ │ crea/aggiorna sessione
|
||||
│ │
|
||||
│ 3) AUTH_OK │
|
||||
│ ←──────────────────────────────────────┤
|
||||
│ {need_full_sync:true/false} │
|
||||
│ │
|
||||
│ se need_full_sync → incrementalSync() │
|
||||
│ │
|
||||
──────────────┼────────────────────────────────────────┼──────────────
|
||||
│ │
|
||||
│ 4) Ricezione evento WS │
|
||||
│ {type:added/removed, event_id} │
|
||||
│ ←──────────────────────────────────────┤
|
||||
│ │
|
||||
│ se event_id già visto → ACK │
|
||||
│ se nuovo → applica evento │
|
||||
│ │
|
||||
│ ACK │
|
||||
├──────────────────────────────────────→│
|
||||
│ │
|
||||
──────────────┼────────────────────────────────────────┼──────────────
|
||||
│ │
|
||||
│ │ 5) Retry loop (ogni 3s)
|
||||
│ │ cerca eventi pendenti
|
||||
│ │
|
||||
│ │ se retries < 40:
|
||||
│ │ reinvia evento
|
||||
│ │
|
||||
│ │ se retries >= 40:
|
||||
│ │ ws.terminate()
|
||||
│ │ deleteSession()
|
||||
│ │ (queue eliminata)
|
||||
│ │
|
||||
──────────────┼────────────────────────────────────────┼──────────────
|
||||
│ │
|
||||
│ 6) Riconnessione client │
|
||||
├──────────────────────────────────────→│
|
||||
│ {auth, token, session_id} │
|
||||
│ │
|
||||
│ │ server vede sessione mancante
|
||||
│ │ → need_full_sync = true
|
||||
│ │
|
||||
│ AUTH_OK │
|
||||
│ ←──────────────────────────────────────┤
|
||||
│ {need_full_sync:true} │
|
||||
│ │
|
||||
│ incrementalSync() │
|
||||
│ │
|
||||
──────────────┴────────────────────────────────────────┴──────────────
|
||||
|
||||
CLIENT → WS CONNECT
|
||||
CLIENT → AUTH(token, session_id)
|
||||
SERVER → AUTH_OK(need_full_sync)
|
||||
|
||||
EVENTO:
|
||||
SERVER → EVENT(event_id)
|
||||
CLIENT:
|
||||
se nuovo → applica + ACK
|
||||
se già visto → solo ACK
|
||||
|
||||
RETRY LOOP:
|
||||
se retries < 40 → reinvia
|
||||
se retries >= 40 → chiudi WS + cancella sessione
|
||||
|
||||
RICONNESSIONE:
|
||||
CLIENT → AUTH(session_id)
|
||||
SERVER → AUTH_OK(need_full_sync=true)
|
||||
CLIENT → incrementalSync()
|
||||
|
||||
______
|
||||
|
||||
|
||||
AUTH → EVENTI → ACK → (retry finché vivo)
|
||||
se client sparisce → timeout → cancella sessione
|
||||
se torna → need_full_sync → incrementalSync
|
||||
761
docs/RICONOSCIMENTO_SERVER.md
Normal file
761
docs/RICONOSCIMENTO_SERVER.md
Normal file
|
|
@ -0,0 +1,761 @@
|
|||
# Riconoscimento facciale nel server s141js
|
||||
|
||||
## Scopo del documento
|
||||
|
||||
Questo documento descrive il funzionamento attuale del riconoscimento facciale nel server `s141js`.
|
||||
|
||||
La soluzione usa due percorsi paralleli:
|
||||
|
||||
- Python produce il risultato ufficiale e lo salva nel database;
|
||||
- OpenCL sulla GPU Mali-G610 ricalcola lo stesso risultato in modalità shadow e verifica la coerenza.
|
||||
|
||||
La modalità shadow non sostituisce ancora il risultato Python e non modifica le valutazioni ufficiali.
|
||||
|
||||
## Legenda dei segnaposto
|
||||
|
||||
Il file usa segnaposto al posto dei caratteri Markdown che possono essere alterati durante la copia in chat o nel terminale.
|
||||
|
||||
```text
|
||||
# titolo livello 1, da convertire in #
|
||||
## titolo livello 2, da convertire in ##
|
||||
### titolo livello 3, da convertire in ###
|
||||
#### titolo livello 4, da convertire in ####
|
||||
__FENCE__ apertura o chiusura di un blocco di codice
|
||||
__FENCE_TEXT__ apertura di un blocco text
|
||||
__FENCE_BASH__ apertura di un blocco bash
|
||||
__FENCE_JSON__ apertura di un blocco json
|
||||
__FENCE_JS__ apertura di un blocco javascript
|
||||
` apice rovescio singolo
|
||||
```
|
||||
|
||||
## Architettura generale
|
||||
|
||||
```text
|
||||
Fotografia
|
||||
|
|
||||
v
|
||||
SCRFD tramite RKNN e NPU
|
||||
|
|
||||
v
|
||||
Bounding box e cinque landmark
|
||||
|
|
||||
v
|
||||
Allineamento del volto a 112 x 112
|
||||
|
|
||||
v
|
||||
SFace tramite RKNN e NPU
|
||||
|
|
||||
v
|
||||
Embedding float32[128] normalizzato L2
|
||||
|
|
||||
+-------------------------------+
|
||||
| |
|
||||
v v
|
||||
Pipeline Python ufficiale kNN OpenCL shadow
|
||||
| |
|
||||
v v
|
||||
Valutazioni salvate Confronto di coerenza
|
||||
nel database Python contro OpenCL
|
||||
| |
|
||||
+---------------+---------------+
|
||||
|
|
||||
v
|
||||
Report e stato del job
|
||||
```
|
||||
|
||||
## Componenti principali
|
||||
|
||||
### Face engine RKNN
|
||||
|
||||
Il face engine usa la NPU del dispositivo RK3588 per:
|
||||
|
||||
- rilevare i volti con SCRFD;
|
||||
- calcolare confidence, bounding box e landmark;
|
||||
- allineare il volto;
|
||||
- generare un embedding SFace;
|
||||
- normalizzare l'embedding con norma L2.
|
||||
|
||||
File principali:
|
||||
|
||||
```text
|
||||
include/face_config.h
|
||||
include/face_engine.h
|
||||
src/face_engine.cc
|
||||
src/face_recognition.cc
|
||||
src/face_scan.cc
|
||||
```
|
||||
|
||||
Ogni embedding ha il seguente formato:
|
||||
|
||||
```text
|
||||
Dimensione: 128 componenti
|
||||
Tipo: float32
|
||||
Dimensione binaria: 512 byte
|
||||
Ordine byte: little-endian
|
||||
Normalizzazione: L2
|
||||
```
|
||||
|
||||
Poiché gli embedding sono normalizzati, la cosine similarity coincide con il prodotto scalare.
|
||||
|
||||
### Motore kNN nativo
|
||||
|
||||
Il motore nativo è installato in:
|
||||
|
||||
```text
|
||||
native/face-knn/
|
||||
```
|
||||
|
||||
File principali:
|
||||
|
||||
```text
|
||||
native/face-knn/bin/face_knn
|
||||
native/face-knn/kernels/knn_float4_topk.cl
|
||||
native/face-knn/run-face-knn.sh
|
||||
native/face-knn/config.json
|
||||
native/face-knn/SHA256SUMS
|
||||
```
|
||||
|
||||
Il launcher `run-face-knn.sh` imposta la directory di lavoro corretta prima di avviare il binario. Questo permette a `face_knn` di trovare il kernel tramite il percorso relativo:
|
||||
|
||||
```text
|
||||
kernels/knn_float4_topk.cl
|
||||
```
|
||||
|
||||
Il motore supporta due backend:
|
||||
|
||||
```text
|
||||
cpu
|
||||
opencl
|
||||
```
|
||||
|
||||
Il backend CPU è il riferimento numerico e il fallback. Il backend OpenCL usa la GPU Mali-G610.
|
||||
|
||||
### Runtime Node.js
|
||||
|
||||
Il modulo:
|
||||
|
||||
```text
|
||||
tools/faceKnnRuntime.js
|
||||
```
|
||||
|
||||
ha queste responsabilità:
|
||||
|
||||
1. legge la configurazione da `native/face-knn/config.json`;
|
||||
2. recupera gli embedding dal database SQLite;
|
||||
3. preserva l'ordine delle query prodotto dall'esperimento Python;
|
||||
4. rilegge i prototype attivi ordinati per `selection_order`;
|
||||
5. verifica che ogni embedding sia un Buffer da 512 byte;
|
||||
6. crea file binari temporanei per query e prototype;
|
||||
7. genera la self-map per il leave-one-out;
|
||||
8. avvia il binario nativo;
|
||||
9. analizza il JSON prodotto;
|
||||
10. elimina i file biometrici temporanei.
|
||||
|
||||
I file temporanei vengono creati sotto:
|
||||
|
||||
```text
|
||||
var/face-knn/run-*
|
||||
```
|
||||
|
||||
Durante l'esecuzione normale vengono rimossi automaticamente.
|
||||
|
||||
### Shadow runner
|
||||
|
||||
Il comando indipendente:
|
||||
|
||||
```text
|
||||
tools/runFaceKnnShadow.js
|
||||
```
|
||||
|
||||
confronta il risultato Python con quello del backend nativo.
|
||||
|
||||
Confronta per ogni query:
|
||||
|
||||
- prototype migliore;
|
||||
- best similarity;
|
||||
- media dei primi tre score;
|
||||
- decisione recognized;
|
||||
- esclusione leave-one-out.
|
||||
|
||||
Il report contiene solo metadati e risultati numerici. Non contiene gli embedding.
|
||||
|
||||
## Database e tabelle utilizzate
|
||||
|
||||
Il database attivo è:
|
||||
|
||||
```text
|
||||
api_v1/database.sqlite
|
||||
```
|
||||
|
||||
Le tabelle principali sono:
|
||||
|
||||
### faces
|
||||
|
||||
Contiene i volti e gli embedding da 512 byte.
|
||||
|
||||
### person_face_prototypes
|
||||
|
||||
Contiene i prototype attivi di ogni persona.
|
||||
|
||||
L'ordine è determinato da:
|
||||
|
||||
```text
|
||||
selection_order
|
||||
```
|
||||
|
||||
La corrispondenza con gli indici OpenCL è:
|
||||
|
||||
```text
|
||||
selection_order 1 -> indice OpenCL 0
|
||||
selection_order 2 -> indice OpenCL 1
|
||||
selection_order 3 -> indice OpenCL 2
|
||||
selection_order 4 -> indice OpenCL 3
|
||||
selection_order 5 -> indice OpenCL 4
|
||||
```
|
||||
|
||||
### face_prototype_jobs
|
||||
|
||||
Contiene i job di ricalcolo dei prototype.
|
||||
|
||||
Stati principali:
|
||||
|
||||
```text
|
||||
pending
|
||||
processing
|
||||
done
|
||||
error
|
||||
superseded
|
||||
```
|
||||
|
||||
### person_face_recognition_evaluations
|
||||
|
||||
Contiene le valutazioni ufficiali prodotte dalla pipeline Python.
|
||||
|
||||
Campi principali:
|
||||
|
||||
```text
|
||||
face_id
|
||||
prototype_job_id
|
||||
prototype_count
|
||||
best_similarity
|
||||
top3_mean
|
||||
best_prototype_face_id
|
||||
recognition_threshold
|
||||
recognized
|
||||
evaluated_at
|
||||
```
|
||||
|
||||
### face_recognition_index_state
|
||||
|
||||
Tiene traccia dello stato dell'indice persistente.
|
||||
|
||||
Stato attuale validato:
|
||||
|
||||
```text
|
||||
user: Fabio
|
||||
status: dirty
|
||||
dirty: 1
|
||||
version: 0
|
||||
prototype_count: 5
|
||||
index_path: null
|
||||
metadata_path: null
|
||||
built_at: null
|
||||
last_error: null
|
||||
```
|
||||
|
||||
Lo stato `dirty` indica che i prototype sono stati ricalcolati, ma l'indice persistente non è ancora stato costruito.
|
||||
|
||||
## Come nasce un job di ricalcolo
|
||||
|
||||
Il router è montato con il prefisso:
|
||||
|
||||
```text
|
||||
/face-review
|
||||
```
|
||||
|
||||
L'endpoint manuale è:
|
||||
|
||||
```text
|
||||
POST /face-review/persons/:personId/prototypes/recalculate
|
||||
```
|
||||
|
||||
La richiesta richiede:
|
||||
|
||||
```text
|
||||
Authorization: Bearer TOKEN
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Esempio di body:
|
||||
|
||||
```json
|
||||
{
|
||||
"reason": "opencl_shadow_validation"
|
||||
}
|
||||
```
|
||||
|
||||
La funzione `queuePrototypeRefresh()`:
|
||||
|
||||
- crea un job pending se non ne esiste uno;
|
||||
- aggiorna il job pending esistente per evitare duplicati;
|
||||
- salva reason, requested_by e timestamp.
|
||||
|
||||
Il ricalcolo viene accodato anche automaticamente quando:
|
||||
|
||||
- viene assegnato un cluster a una persona;
|
||||
- un volto viene rimosso da una persona;
|
||||
- un volto noise viene assegnato;
|
||||
- un volto viene riassegnato;
|
||||
- più volti noise vengono assegnati insieme.
|
||||
|
||||
## Elaborazione del job
|
||||
|
||||
Il worker è:
|
||||
|
||||
```text
|
||||
tools/runPrototypeWorker.js
|
||||
```
|
||||
|
||||
Esecuzione manuale:
|
||||
|
||||
```bash
|
||||
node tools/runPrototypeWorker.js \
|
||||
--persist \
|
||||
--job-id JOB_ID
|
||||
```
|
||||
|
||||
Il flusso è il seguente.
|
||||
|
||||
### 1. Acquisizione del job
|
||||
|
||||
Il worker cerca un job con stato `pending`, lo porta a `processing` e incrementa `attempts`.
|
||||
|
||||
### 2. Firma degli input
|
||||
|
||||
Il worker calcola una firma SHA-256 basata sui volti e sulle esclusioni di riferimento.
|
||||
|
||||
Se la firma cambia durante il lavoro, il job viene marcato `superseded` e viene creato un job sostitutivo.
|
||||
|
||||
### 3. Esperimento Python
|
||||
|
||||
Il worker avvia:
|
||||
|
||||
```text
|
||||
tools/prototype_experiment.py
|
||||
```
|
||||
|
||||
Parametri attuali:
|
||||
|
||||
```text
|
||||
noise_prototypes: 2
|
||||
greedy_additions: 2
|
||||
min_detector_score: 0.60
|
||||
```
|
||||
|
||||
L'esperimento produce:
|
||||
|
||||
```text
|
||||
prototype_sets.greedy_gain
|
||||
evaluations.greedy_gain.queries
|
||||
evaluations.greedy_gain.summary
|
||||
```
|
||||
|
||||
Ogni query Python contiene:
|
||||
|
||||
```text
|
||||
face_id
|
||||
photo_name
|
||||
is_prototype
|
||||
best_similarity
|
||||
best_prototype_face_id
|
||||
top3_mean
|
||||
```
|
||||
|
||||
### 4. Selezione dei prototype
|
||||
|
||||
Il worker:
|
||||
|
||||
1. applica le esclusioni manuali;
|
||||
2. mantiene i prototype bloccati;
|
||||
3. disattiva i prototype automatici precedenti;
|
||||
4. salva i nuovi prototype;
|
||||
5. assegna `selection_order` a partire da 1.
|
||||
|
||||
Per Jessica l'ordine validato è:
|
||||
|
||||
```text
|
||||
0 IMG_0145.JPG cluster_medoid
|
||||
1 IMG_0141.JPG cluster_medoid
|
||||
2 IMG_0193.JPG cluster_medoid
|
||||
3 IMG_0143.JPG greedy_leave_one_out_gain
|
||||
4 IMG_0146.JPG greedy_leave_one_out_gain
|
||||
```
|
||||
|
||||
Il numero a sinistra è l'indice kNN, cioè `selection_order - 1`.
|
||||
|
||||
### 5. Valutazioni Python ufficiali
|
||||
|
||||
La soglia attuale è:
|
||||
|
||||
```text
|
||||
0.50
|
||||
```
|
||||
|
||||
Le valutazioni precedenti vengono sostituite atomicamente nella transazione SQLite.
|
||||
|
||||
Il risultato Python viene salvato in `person_face_recognition_evaluations` ed è ancora il risultato ufficiale.
|
||||
|
||||
### 6. Aggiornamento dello stato indice
|
||||
|
||||
Dopo il ricalcolo dei prototype:
|
||||
|
||||
```text
|
||||
status = dirty
|
||||
dirty = 1
|
||||
prototype_count = numero dei prototype attivi
|
||||
last_error = null
|
||||
```
|
||||
|
||||
### 7. Chiusura della transazione
|
||||
|
||||
`persistResult()` conclude la transazione prima dell'avvio OpenCL.
|
||||
|
||||
Questo evita di mantenere un lock SQLite durante:
|
||||
|
||||
- creazione dei file binari;
|
||||
- inizializzazione OpenCL;
|
||||
- compilazione del kernel;
|
||||
- esecuzione del kNN;
|
||||
- scrittura del report shadow.
|
||||
|
||||
### 8. Shadow OpenCL
|
||||
|
||||
Dopo il commit della transazione, il worker chiama `runFaceKnnShadow()`.
|
||||
|
||||
La sequenza è:
|
||||
|
||||
```text
|
||||
Risultato Python già salvato
|
||||
|
|
||||
v
|
||||
Recupero embedding query nello stesso ordine Python
|
||||
|
|
||||
v
|
||||
Recupero prototype ordinati per selection_order
|
||||
|
|
||||
v
|
||||
Generazione queries.bin
|
||||
Generazione prototypes.bin
|
||||
Generazione self-map.txt
|
||||
|
|
||||
v
|
||||
face_knn --backend opencl
|
||||
|
|
||||
v
|
||||
Confronto Python contro OpenCL
|
||||
|
|
||||
v
|
||||
Report shadow JSON
|
||||
```
|
||||
|
||||
## Leave-one-out
|
||||
|
||||
Se una query è anche un prototype, il confronto con sé stessa deve essere escluso.
|
||||
|
||||
Il runtime costruisce una mappa tra `face_id` e indice del prototype.
|
||||
|
||||
La self-map contiene:
|
||||
|
||||
```text
|
||||
-1 -> nessuna esclusione
|
||||
0 -> escludi prototype indice 0
|
||||
1 -> escludi prototype indice 1
|
||||
2 -> escludi prototype indice 2
|
||||
...
|
||||
```
|
||||
|
||||
Il kernel calcola il TOPK completo. L'esclusione viene applicata sul risultato host.
|
||||
|
||||
## Output del motore nativo
|
||||
|
||||
Il binario mantiene separati i flussi:
|
||||
|
||||
```text
|
||||
stdout -> JSON valido
|
||||
stderr -> diagnostica OpenCL e messaggi del driver Mali
|
||||
```
|
||||
|
||||
Il runtime Mali può scrivere direttamente:
|
||||
|
||||
```text
|
||||
arm_release_ver: g24p0-00eac0, rk_so_ver: 10
|
||||
```
|
||||
|
||||
La CLI devia temporaneamente stdout verso stderr durante OpenCL e lo ripristina prima di scrivere il JSON.
|
||||
|
||||
## Controlli shadow
|
||||
|
||||
Per ogni query vengono confrontati:
|
||||
|
||||
- prototype migliore Python e OpenCL;
|
||||
- best similarity;
|
||||
- media dei primi tre score;
|
||||
- decisione recognized;
|
||||
- leave-one-out.
|
||||
|
||||
Tolleranza configurata:
|
||||
|
||||
```text
|
||||
0.00001
|
||||
```
|
||||
|
||||
Campi aggregati del report:
|
||||
|
||||
```text
|
||||
query_count
|
||||
prototype_count
|
||||
top1_matches
|
||||
decision_matches
|
||||
best_score_matches
|
||||
top3_mean_matches
|
||||
maximum_score_error
|
||||
maximum_top3_mean_error
|
||||
mismatch_count
|
||||
```
|
||||
|
||||
I report automatici vengono scritti in:
|
||||
|
||||
```text
|
||||
var/face-knn/shadow-JOB_ID.json
|
||||
```
|
||||
|
||||
Il report non contiene embedding.
|
||||
|
||||
## Gestione degli errori
|
||||
|
||||
### Errore Python o persistenza
|
||||
|
||||
```text
|
||||
Il job viene marcato error.
|
||||
last_error viene valorizzato.
|
||||
Il worker restituisce exit code non zero.
|
||||
```
|
||||
|
||||
### Errore OpenCL shadow
|
||||
|
||||
```text
|
||||
Il job Python resta done.
|
||||
Le valutazioni ufficiali restano salvate.
|
||||
L'errore viene registrato nel log shadow.
|
||||
Non viene effettuato rollback.
|
||||
```
|
||||
|
||||
### Mismatch Python/OpenCL
|
||||
|
||||
```text
|
||||
Il job Python resta done.
|
||||
Il report contiene mismatch_count maggiore di zero.
|
||||
OpenCL non viene promosso a motore ufficiale.
|
||||
```
|
||||
|
||||
## Configurazione attuale
|
||||
|
||||
File:
|
||||
|
||||
```text
|
||||
native/face-knn/config.json
|
||||
```
|
||||
|
||||
Configurazione validata:
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"mode": "shadow",
|
||||
"backend": "opencl",
|
||||
"fallbackBackend": "cpu",
|
||||
"dimension": 128,
|
||||
"topK": 5,
|
||||
"recognitionThreshold": 0.5,
|
||||
"scoreTolerance": 0.00001,
|
||||
"autoBackendMinimumComparisons": 100000,
|
||||
"launcher": "native/face-knn/run-face-knn.sh",
|
||||
"indexDirectory": "api_v1/face-recognition-index",
|
||||
"runtimeDirectory": "var/face-knn"
|
||||
}
|
||||
```
|
||||
|
||||
## Validazione completata
|
||||
|
||||
Persona validata:
|
||||
|
||||
```text
|
||||
User: Fabio
|
||||
Persona: Jessica
|
||||
Person ID: 77456100-d7b2-4c36-b3d6-09f8c1c9d7e7
|
||||
Query: 21
|
||||
Prototype: 5
|
||||
```
|
||||
|
||||
Risultato funzionale:
|
||||
|
||||
```text
|
||||
Riconosciuti: 20
|
||||
Sotto soglia: 1
|
||||
Copertura: 95.2%
|
||||
Caso sotto soglia: IMG_0183.JPG
|
||||
Score: 0.4664280414581299
|
||||
Miglior prototype: IMG_0143.JPG
|
||||
Indice prototype: 3
|
||||
```
|
||||
|
||||
Risultato shadow automatico:
|
||||
|
||||
```text
|
||||
Top-1 identici: 21 / 21
|
||||
Decisioni identiche: 21 / 21
|
||||
Best score entro tolleranza: 21 / 21
|
||||
Top-3 mean entro tolleranza: 21 / 21
|
||||
Mismatch: 0
|
||||
Errore massimo best score: 1.7869088742017425e-7
|
||||
Errore massimo top-3 mean: 1.3911677043676463e-7
|
||||
```
|
||||
|
||||
Job validato:
|
||||
|
||||
```text
|
||||
Job ID: 7741c033-dae9-4b6f-b00f-bc082f08b8a1
|
||||
Stato: done
|
||||
Tentativi: 1
|
||||
Reason: opencl_shadow_validation
|
||||
Last error: null
|
||||
```
|
||||
|
||||
## Stato attuale della produzione
|
||||
|
||||
```text
|
||||
Python:
|
||||
- seleziona i prototype;
|
||||
- calcola le valutazioni;
|
||||
- salva il risultato ufficiale.
|
||||
|
||||
OpenCL:
|
||||
- ricalcola il kNN dopo il commit;
|
||||
- confronta il risultato;
|
||||
- genera il report shadow;
|
||||
- non modifica il risultato ufficiale.
|
||||
```
|
||||
|
||||
## Prossimi passi
|
||||
|
||||
1. creare il builder dell'indice persistente;
|
||||
2. generare atomicamente embeddings e metadati;
|
||||
3. portare `face_recognition_index_state` da dirty a ready;
|
||||
4. mantenere la modalità shadow per più job reali;
|
||||
5. definire criteri di promozione OpenCL;
|
||||
6. mantenere il backend CPU come fallback;
|
||||
7. passare a OpenCL ufficiale solo dopo validazioni sufficienti.
|
||||
|
||||
## Comandi operativi utili
|
||||
|
||||
### Controllare i job pending
|
||||
|
||||
```bash
|
||||
sqlite3 -header -column \
|
||||
api_v1/database.sqlite \
|
||||
"
|
||||
SELECT
|
||||
id,
|
||||
user,
|
||||
person_id,
|
||||
status,
|
||||
reason,
|
||||
requested_at
|
||||
FROM face_prototype_jobs
|
||||
WHERE status = 'pending'
|
||||
ORDER BY requested_at;
|
||||
"
|
||||
```
|
||||
|
||||
### Eseguire un job specifico
|
||||
|
||||
```bash
|
||||
node tools/runPrototypeWorker.js \
|
||||
--persist \
|
||||
--job-id "JOB_ID"
|
||||
```
|
||||
|
||||
### Validare un report shadow
|
||||
|
||||
```bash
|
||||
python3 -m json.tool \
|
||||
"var/face-knn/shadow-JOB_ID.json" \
|
||||
> /dev/null \
|
||||
&& echo "OK: report shadow valido"
|
||||
```
|
||||
|
||||
### Controllare le valutazioni ufficiali
|
||||
|
||||
```bash
|
||||
sqlite3 -header -column \
|
||||
api_v1/database.sqlite \
|
||||
"
|
||||
SELECT
|
||||
COUNT(*) AS evaluations,
|
||||
SUM(CASE WHEN recognized = 1 THEN 1 ELSE 0 END)
|
||||
AS recognized_faces,
|
||||
SUM(CASE WHEN recognized = 0 THEN 1 ELSE 0 END)
|
||||
AS below_threshold
|
||||
FROM person_face_recognition_evaluations;
|
||||
"
|
||||
```
|
||||
|
||||
### Controllare lo stato dell'indice
|
||||
|
||||
```bash
|
||||
sqlite3 -header -column \
|
||||
api_v1/database.sqlite \
|
||||
"
|
||||
SELECT
|
||||
user,
|
||||
status,
|
||||
dirty,
|
||||
version,
|
||||
prototype_count,
|
||||
index_path,
|
||||
metadata_path,
|
||||
built_at,
|
||||
last_error
|
||||
FROM face_recognition_index_state
|
||||
WHERE user = 'Fabio';
|
||||
"
|
||||
```
|
||||
|
||||
## Conversione in Markdown standard
|
||||
|
||||
Per generare un file Markdown normale:
|
||||
|
||||
```bash
|
||||
sed \
|
||||
-e 's/^__H4__[[:space:]]*/#### /' \
|
||||
-e 's/^__H3__[[:space:]]*/### /' \
|
||||
-e 's/^__H2__[[:space:]]*/## /' \
|
||||
-e 's/^__H1__[[:space:]]*/# /' \
|
||||
-e 's/^__FENCE_TEXT__$/```text/' \
|
||||
-e 's/^__FENCE_BASH__$/```bash/' \
|
||||
-e 's/^__FENCE_JSON__$/```json/' \
|
||||
-e 's/^__FENCE_JS__$/```javascript/' \
|
||||
-e 's/^__FENCE__$/```/' \
|
||||
-e 's/`/`/g' \
|
||||
RICONOSCIMENTO_SERVER.src.md \
|
||||
> RICONOSCIMENTO_SERVER.md
|
||||
```
|
||||
|
||||
Verifica che non siano rimasti segnaposto:
|
||||
|
||||
```bash
|
||||
grep -nE \
|
||||
'__H[1-4]__|__FENCE|`' \
|
||||
RICONOSCIMENTO_SERVER.md \
|
||||
|| echo "OK: segnaposto convertiti"
|
||||
```
|
||||
341
docs/activity_scan.md
Normal file
341
docs/activity_scan.md
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
Certo. Dopo le modifiche in s179js, il flusso Activity è ormai molto simile a quello Photo, ma la notifica alla gallery segue ancora il percorso tramite POST /activities.
|
||||
|
||||
1. Flusso attuale di scansione Activity
|
||||
|
||||
Quando premi Scan Activities:
|
||||
|
||||
UTENTE
|
||||
│
|
||||
▼
|
||||
Scan Activities
|
||||
│
|
||||
▼
|
||||
routes/scanActivities.js
|
||||
│
|
||||
▼
|
||||
scanActivitiesUser()
|
||||
│
|
||||
trova cartelle Activity
|
||||
│
|
||||
▼
|
||||
scanActivity()
|
||||
│
|
||||
▼
|
||||
scanActivityCartella()
|
||||
│
|
||||
trova FIT/GPX/TCX
|
||||
│
|
||||
▼
|
||||
enqueueActivityIngestJob()
|
||||
│
|
||||
▼
|
||||
activity_ingest_jobs
|
||||
│
|
||||
┌─────────────┼──────────────┐
|
||||
│ │ │
|
||||
pending processing done
|
||||
│
|
||||
▼
|
||||
│ ACTIVITY DAEMON [A]
|
||||
└────────────►│
|
||||
▼
|
||||
runOneJob()
|
||||
│
|
||||
▼
|
||||
scanActivitySingle()
|
||||
│
|
||||
▼
|
||||
processActivityFile()
|
||||
│
|
||||
parsing FIT/GPX/TCX
|
||||
│
|
||||
▼
|
||||
activities DB
|
||||
│
|
||||
├───────────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
geocode_jobs ritorno meta
|
||||
│
|
||||
▼
|
||||
GEOCODE DAEMON [G]
|
||||
│
|
||||
▼
|
||||
geocoding
|
||||
|
||||
Lo Scan adesso è solo il produttore
|
||||
|
||||
Questa è la differenza fondamentale rispetto a prima.
|
||||
|
||||
Prima:
|
||||
|
||||
Scan
|
||||
↓
|
||||
trova file
|
||||
↓
|
||||
parsa FIT
|
||||
↓
|
||||
salva Activity
|
||||
↓
|
||||
file successivo
|
||||
|
||||
|
||||
Adesso:
|
||||
|
||||
Scan
|
||||
↓
|
||||
trova file
|
||||
↓
|
||||
mette job pending
|
||||
↓
|
||||
file successivo
|
||||
|
||||
|
||||
Per questo nel log vedi velocemente:
|
||||
|
||||
[S] 📥 [ACTIVITY QUEUE] xxx.fit status=pending inserted
|
||||
[S] 📥 [ACTIVITY QUEUE] yyy.fit status=pending inserted
|
||||
[S] 📥 [ACTIVITY QUEUE] zzz.fit status=pending inserted
|
||||
|
||||
|
||||
e poi:
|
||||
|
||||
[S] Scan ATTIVITÀ COMPLETATO
|
||||
|
||||
|
||||
Questo però non significa che tutte le Activity siano elaborate. Significa solamente che la fase di discovery ha finito di riempire la coda.
|
||||
|
||||
2. Il daemon svuota la coda
|
||||
|
||||
Parallelamente [A] fa:
|
||||
|
||||
activity_ingest_jobs
|
||||
│
|
||||
▼
|
||||
prende primo pending
|
||||
│
|
||||
▼
|
||||
processing
|
||||
│
|
||||
▼
|
||||
parse FIT
|
||||
│
|
||||
▼
|
||||
salva Activity
|
||||
│
|
||||
▼
|
||||
accoda Geocode
|
||||
│
|
||||
▼
|
||||
sincronizza server
|
||||
│
|
||||
▼
|
||||
done
|
||||
│
|
||||
▼
|
||||
prende IMMEDIATAMENTE
|
||||
il pending successivo
|
||||
|
||||
|
||||
Infatti nel tuo log:
|
||||
|
||||
[A] Avvio job ...
|
||||
[A] Nuova activity ...
|
||||
[A] Activity ... inviata al server remoto
|
||||
[A] Job ... completato; stato: done
|
||||
[A] Avvio job successivo ...
|
||||
|
||||
|
||||
Non ci sono 2 secondi tra un'attività e l'altra.
|
||||
|
||||
I 2 secondi servono solo quando:
|
||||
|
||||
pending = 0
|
||||
|
||||
3. Geocode è completamente indipendente
|
||||
|
||||
Quando scanActivitySingle() salva l'Activity esegue anche:
|
||||
|
||||
enqueueGeocodeJob
|
||||
|
||||
|
||||
quindi:
|
||||
|
||||
ACTIVITY DAEMON
|
||||
│
|
||||
▼
|
||||
activities DB
|
||||
│
|
||||
└────────────► geocode_jobs
|
||||
│
|
||||
▼
|
||||
GEOCODE DAEMON [G]
|
||||
│
|
||||
▼
|
||||
geocoding
|
||||
|
||||
|
||||
Activity daemon non aspetta che il Geocode finisca.
|
||||
|
||||
Per questo nel log puoi vedere:
|
||||
|
||||
[A] job Activity successivo
|
||||
|
||||
|
||||
mentre:
|
||||
|
||||
[G] geocodeWorker processing
|
||||
|
||||
|
||||
sta ancora lavorando sull'attività precedente.
|
||||
|
||||
4. Flusso attuale di notifica alla gallery Activity
|
||||
|
||||
Qui il percorso è leggermente diverso da Photo.
|
||||
|
||||
Quando scanActivitySingle() restituisce la nuova Activity:
|
||||
|
||||
ACTIVITY DAEMON [A]
|
||||
│
|
||||
▼
|
||||
scannedActivity/meta
|
||||
│
|
||||
▼
|
||||
postWithAuth()
|
||||
│
|
||||
▼
|
||||
POST BASE_URL/activities
|
||||
│
|
||||
▼
|
||||
routes/activities.js
|
||||
│
|
||||
▼
|
||||
INSERT activity
|
||||
ON CONFLICT MERGE
|
||||
│
|
||||
▼
|
||||
invalidateActivitySpatialIndex()
|
||||
│
|
||||
▼
|
||||
broadcastToUser()
|
||||
│
|
||||
payload:
|
||||
type: "added"
|
||||
id: activityId
|
||||
entity: "activity"
|
||||
│
|
||||
▼
|
||||
WebSocket
|
||||
│
|
||||
▼
|
||||
CLIENT
|
||||
│
|
||||
▼
|
||||
Activity Gallery
|
||||
|
||||
|
||||
Quindi la notifica alla gallery non viene inviata direttamente dall'Activity daemon.
|
||||
|
||||
Il daemon fa:
|
||||
|
||||
Activity daemon
|
||||
↓
|
||||
HTTP POST
|
||||
↓
|
||||
server
|
||||
↓
|
||||
WebSocket
|
||||
↓
|
||||
Gallery
|
||||
|
||||
|
||||
Questo è importante perché il processo [A] non importa ws-server e quindi non rischia di aprire un secondo WebSocket sulla porta 4002.
|
||||
|
||||
5. Pipeline completa
|
||||
|
||||
Mettendo insieme tutto:
|
||||
|
||||
SCAN
|
||||
│
|
||||
▼
|
||||
trova FIT/GPX/TCX
|
||||
│
|
||||
▼
|
||||
activity_ingest_jobs
|
||||
│
|
||||
│ pending
|
||||
▼
|
||||
ACTIVITY DAEMON [A]
|
||||
│
|
||||
▼
|
||||
parse FIT/GPX/TCX
|
||||
│
|
||||
▼
|
||||
activities DB
|
||||
│
|
||||
┌────────────┴─────────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
geocode_jobs activity meta
|
||||
│ │
|
||||
▼ ▼
|
||||
GEOCODE DAEMON [G] POST /activities
|
||||
│
|
||||
▼
|
||||
SERVER
|
||||
│
|
||||
INSERT / UPDATE
|
||||
│
|
||||
▼
|
||||
WebSocket
|
||||
│
|
||||
▼
|
||||
ACTIVITY GALLERY
|
||||
|
||||
Activity daemon
|
||||
│
|
||||
▼
|
||||
job status = done
|
||||
│
|
||||
▼
|
||||
prossimo pending
|
||||
|
||||
6. Cosa significa per il futuro banner
|
||||
|
||||
Ora abbiamo finalmente una coda affidabile:
|
||||
|
||||
activity_ingest_jobs
|
||||
|
||||
|
||||
quindi il banner Activity non deve più basarsi sul vecchio:
|
||||
|
||||
scan_status.json
|
||||
|
||||
|
||||
Può mostrare direttamente:
|
||||
|
||||
remaining = pending + processing
|
||||
|
||||
|
||||
Per esempio:
|
||||
|
||||
Activities processing: 27
|
||||
|
||||
|
||||
poi:
|
||||
|
||||
Activities processing: 26
|
||||
Activities processing: 25
|
||||
...
|
||||
Activities processing: 1
|
||||
|
||||
|
||||
e quando:
|
||||
|
||||
pending = 0
|
||||
processing = 0
|
||||
|
||||
|
||||
il banner scompare.
|
||||
|
||||
Questo sarebbe molto più corretto del vecchio progress dello Scan, perché lo Scan può aver finito di accodare i file mentre l'Activity daemon ha ancora 30 FIT da elaborare.
|
||||
15
docs/cast.md
Normal file
15
docs/cast.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
./transcode_rk_vod.sh ../IMG_1425.MOV ../fabiocast
|
||||
python3 hls_cors_server.py ../fabiocast -p 9999
|
||||
catt -d 192.168.1.15 cast http://192.168.1.4:9999/master.m3u8
|
||||
|
||||
|
||||
./transcode_rk_vod.sh \
|
||||
../IMG_1425.MOV \
|
||||
../fabiocast
|
||||
|
||||
python3 hls_cors_server.py \
|
||||
../fabiocast \
|
||||
-p 9999
|
||||
|
||||
catt -d 192.168.1.15 cast \
|
||||
http://192.168.1.4:9999/master.m3u8
|
||||
202
docs/come funziona.md
Normal file
202
docs/come funziona.md
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
Perfetto Fabio — ti preparo **la guida completa, chiara e pratica** per integrare il tuo **viewer Flutter Android** con il **WebSocket del tuo server Node.js**, così come funziona oggi nel tuo backend.
|
||||
|
||||
Ti spiego:
|
||||
|
||||
1. **Come si autentica il client Flutter**
|
||||
2. **Come si connette al WebSocket**
|
||||
3. **Quali messaggi riceve** (formato esatto)
|
||||
4. **Come deve rispondere**
|
||||
5. **Come integrare tutto nel viewer**
|
||||
|
||||
Tutto basato sul tuo backend reale.
|
||||
|
||||
---
|
||||
|
||||
# ⭐ 1. COME SI AUTENTICA IL CLIENT FLUTTER
|
||||
|
||||
Il tuo server usa JWT.
|
||||
|
||||
### Passi:
|
||||
|
||||
1. Flutter fa login via HTTP:
|
||||
|
||||
```
|
||||
POST /auth/login
|
||||
{
|
||||
"email": "...",
|
||||
"password": "..."
|
||||
}
|
||||
```
|
||||
|
||||
2. Il server risponde:
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "<JWT>",
|
||||
"name": "Fabio"
|
||||
}
|
||||
```
|
||||
|
||||
3. Flutter salva il token.
|
||||
|
||||
4. **Ogni chiamata HTTP successiva** deve avere:
|
||||
|
||||
```
|
||||
Authorization: Bearer <JWT>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# ⭐ 2. COME SI CONNETTE AL WEBSOCKET
|
||||
|
||||
Il tuo server WebSocket accetta connessioni così:
|
||||
|
||||
```
|
||||
ws://<server>:<port>/?token=<JWT>
|
||||
```
|
||||
|
||||
Quindi in Flutter:
|
||||
|
||||
```dart
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
final channel = WebSocketChannel.connect(
|
||||
Uri.parse("ws://192.168.1.10:4000/?token=$jwt"),
|
||||
);
|
||||
```
|
||||
|
||||
### Importante:
|
||||
- Devi passare **lo stesso JWT** usato per le API HTTP.
|
||||
- Il server autentica il token e associa la connessione all’utente.
|
||||
|
||||
---
|
||||
|
||||
# ⭐ 3. QUALI MESSAGGI RICEVE FLUTTER (FORMATO ESATTO)
|
||||
|
||||
Il tuo backend invia questi eventi:
|
||||
|
||||
### 📌 Quando una foto viene aggiunta
|
||||
```json
|
||||
{
|
||||
"type": "added",
|
||||
"id": "12345"
|
||||
}
|
||||
```
|
||||
|
||||
### 📌 Quando una foto viene rimossa (DEL)
|
||||
```json
|
||||
{
|
||||
"type": "removed",
|
||||
"id": "12345"
|
||||
}
|
||||
```
|
||||
|
||||
### 📌 Quando una cartella viene aggiunta (ADD_DIR)
|
||||
```json
|
||||
{
|
||||
"type": "add_dir",
|
||||
"folder": "Vacanze/2024"
|
||||
}
|
||||
```
|
||||
|
||||
### 📌 Quando una cartella viene rimossa (DEL_DIR)
|
||||
```json
|
||||
{
|
||||
"type": "del_dir",
|
||||
"folder": "Vacanze/2024"
|
||||
}
|
||||
```
|
||||
|
||||
### 📌 Quando una foto viene soft-deleted / restored
|
||||
```json
|
||||
{
|
||||
"type": "updated",
|
||||
"id": "12345",
|
||||
"deleted_at": "2026-03-31T10:00:00Z" // oppure null
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# ⭐ 4. COME FLUTTER DEVE GESTIRE I MESSAGGI
|
||||
|
||||
Esempio completo:
|
||||
|
||||
```dart
|
||||
channel.stream.listen((message) {
|
||||
final data = jsonDecode(message);
|
||||
|
||||
switch (data["type"]) {
|
||||
case "added":
|
||||
// Scarica la foto via /photos/:id
|
||||
fetchPhoto(data["id"]);
|
||||
break;
|
||||
|
||||
case "removed":
|
||||
// Rimuovi la foto dalla UI
|
||||
removePhotoFromUI(data["id"]);
|
||||
break;
|
||||
|
||||
case "updated":
|
||||
// Aggiorna soft delete
|
||||
updatePhotoState(data["id"], data["deleted_at"]);
|
||||
break;
|
||||
|
||||
case "add_dir":
|
||||
// Aggiorna lista cartelle
|
||||
addFolderToUI(data["folder"]);
|
||||
break;
|
||||
|
||||
case "del_dir":
|
||||
// Rimuovi cartella dalla UI
|
||||
removeFolderFromUI(data["folder"]);
|
||||
break;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# ⭐ 5. COME DEVE RISPONDERE FLUTTER AL SERVER
|
||||
|
||||
Il tuo server **non richiede risposte** dal client WebSocket.
|
||||
|
||||
È **one-way**:
|
||||
|
||||
- Il server invia eventi
|
||||
- Il client li riceve
|
||||
- Il client aggiorna la UI
|
||||
|
||||
Flutter **non deve inviare nulla** al WebSocket.
|
||||
|
||||
---
|
||||
|
||||
# ⭐ 6. COME INTEGRARE TUTTO NEL VIEWER
|
||||
|
||||
### Al login:
|
||||
- fai login HTTP
|
||||
- salva il JWT
|
||||
- apri il WebSocket
|
||||
|
||||
### All’avvio del viewer:
|
||||
1. Carica tutte le foto via:
|
||||
```
|
||||
GET /photos
|
||||
```
|
||||
2. Avvia il WebSocket
|
||||
3. Applica in tempo reale:
|
||||
- added → aggiungi foto
|
||||
- removed → rimuovi foto
|
||||
- updated → aggiorna stato
|
||||
- add_dir → aggiungi cartella
|
||||
- del_dir → rimuovi cartella
|
||||
|
||||
### Dopo 2 minuti (quando WS non è più affidabile):
|
||||
- usa progressive sync:
|
||||
|
||||
```
|
||||
GET /photos/changes?since=<timestamp>
|
||||
GET /photos/deleted_hard?since=<timestamp>
|
||||
```
|
||||
|
||||
---
|
||||
207
docs/face-knn-source/COMPILAZIONE_FACE_KNN.md
Normal file
207
docs/face-knn-source/COMPILAZIONE_FACE_KNN.md
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
# Face KNN OpenCL - Note di compilazione
|
||||
|
||||
## Repository
|
||||
|
||||
Directory sorgente:
|
||||
|
||||
/home/nvme/dockerdata/prove/face-clustering-rk3588-knn-opencl-validated
|
||||
|
||||
Questo repository contiene i sorgenti del motore KNN utilizzato dai progetti s1xxjs/s190js.
|
||||
|
||||
Il binario distribuito è:
|
||||
|
||||
bin/face_knn
|
||||
|
||||
SHA256 del binario originale:
|
||||
|
||||
e74bd672eebaa0f10028e25c0f7051e981cd16c2eeb9c529f513a6c412c11033
|
||||
|
||||
---
|
||||
|
||||
## File principali
|
||||
|
||||
### Motore KNN
|
||||
|
||||
src/face_knn.cc
|
||||
|
||||
Gestisce:
|
||||
|
||||
- parsing input
|
||||
- caricamento embeddings
|
||||
- scelta CPU/OpenCL
|
||||
- output risultati
|
||||
|
||||
### Backend OpenCL
|
||||
|
||||
src/knn_opencl.cc
|
||||
|
||||
Implementazione KNN GPU.
|
||||
|
||||
### Integrazione Mali G610
|
||||
|
||||
src/knn_opencl_backend.cc
|
||||
|
||||
Gestisce:
|
||||
|
||||
- ricerca device OpenCL
|
||||
- compilazione kernel
|
||||
- allocazione buffer
|
||||
- esecuzione GPU
|
||||
|
||||
### Kernel GPU
|
||||
|
||||
kernels/knn_float4_topk.cl
|
||||
|
||||
Kernel OpenCL eseguito sulla Mali G610.
|
||||
|
||||
---
|
||||
|
||||
## Verifica binario
|
||||
|
||||
Verifica hash:
|
||||
|
||||
sha256sum bin/face_knn
|
||||
|
||||
Hash atteso:
|
||||
|
||||
e74bd672eebaa0f10028e25c0f7051e981cd16c2eeb9c529f513a6c412c11033
|
||||
|
||||
---
|
||||
|
||||
## Librerie richieste
|
||||
|
||||
Il binario utilizza:
|
||||
|
||||
- libOpenCL.so.1
|
||||
- libstdc++.so.6
|
||||
- libgcc_s.so.1
|
||||
- libc.so.6
|
||||
- libm.so.6
|
||||
|
||||
Verifica:
|
||||
|
||||
ldd bin/face_knn
|
||||
|
||||
---
|
||||
|
||||
## Simboli principali
|
||||
|
||||
Verifica presenza delle funzioni:
|
||||
|
||||
nm -C bin/face_knn | grep search_opencl
|
||||
nm -C bin/face_knn | grep search_cpu
|
||||
|
||||
Devono comparire:
|
||||
|
||||
face_knn::search_opencl()
|
||||
face_knn::search_opencl_impl()
|
||||
face_knn::search_cpu()
|
||||
|
||||
---
|
||||
|
||||
## Compilazione (da verificare)
|
||||
|
||||
La build originale non è presente nel Makefile attuale.
|
||||
|
||||
Comando ricostruito:
|
||||
|
||||
g++ \
|
||||
-std=c++17 \
|
||||
-O2 \
|
||||
-Wall \
|
||||
-Wextra \
|
||||
-Wpedantic \
|
||||
-Iinclude \
|
||||
src/face_knn.cc \
|
||||
src/knn_opencl.cc \
|
||||
src/knn_opencl_backend.cc \
|
||||
-lOpenCL \
|
||||
-ldl \
|
||||
-lpthread \
|
||||
-o bin/face_knn
|
||||
|
||||
NOTA:
|
||||
Prima di utilizzarlo verificare eventuali dipendenze mancanti.
|
||||
|
||||
---
|
||||
|
||||
## Evidenze raccolte
|
||||
|
||||
Il binario contiene riferimenti diretti a:
|
||||
|
||||
- face_knn.cc
|
||||
- knn_opencl.cc
|
||||
- knn_opencl_backend.cc
|
||||
|
||||
Verifica:
|
||||
|
||||
strings bin/face_knn | grep -i "face_knn.cc\\|knn_opencl"
|
||||
|
||||
Il binario esporta:
|
||||
|
||||
- face_knn::search_opencl()
|
||||
- face_knn::search_opencl_impl()
|
||||
- face_knn::search_cpu()
|
||||
|
||||
Verifica:
|
||||
|
||||
nm -C bin/face_knn | grep search_
|
||||
|
||||
---
|
||||
|
||||
## Utilizzo nei progetti NodeJS
|
||||
|
||||
Il binario è utilizzato da:
|
||||
|
||||
native/face-knn/bin/face_knn
|
||||
|
||||
richiamato tramite:
|
||||
|
||||
tools/faceKnnRuntime.js
|
||||
|
||||
nei progetti s141js → s190js.
|
||||
|
||||
|
||||
## Provenienza verificata
|
||||
|
||||
Il binario utilizzato nei progetti s141js → s190js:
|
||||
|
||||
native/face-knn/bin/face_knn
|
||||
|
||||
ha SHA256:
|
||||
|
||||
e74bd672eebaa0f10028e25c0f7051e981cd16c2eeb9c529f513a6c412c11033
|
||||
|
||||
Lo stesso hash è stato trovato nei repository:
|
||||
|
||||
- face-recognition-rk3588-opencl
|
||||
- face-clustering-rk3588-knn-opencl
|
||||
- face-clustering-rk3588-knn-opencl-validated
|
||||
|
||||
Verifiche effettuate:
|
||||
|
||||
- strings bin/face_knn
|
||||
- nm -C bin/face_knn
|
||||
- ldd bin/face_knn
|
||||
- sha256sum bin/face_knn
|
||||
|
||||
Risultato:
|
||||
|
||||
Il binario deriva dai sorgenti:
|
||||
|
||||
- src/face_knn.cc
|
||||
- src/knn_opencl.cc
|
||||
- src/knn_opencl_backend.cc
|
||||
|
||||
e utilizza il kernel:
|
||||
|
||||
- kernels/knn_float4_topk.cl
|
||||
|
||||
Target GPU:
|
||||
|
||||
- ARM Mali-G610 (RK3588 / Orange Pi 5 Plus)
|
||||
|
||||
Data documentazione:
|
||||
|
||||
2026-10-26
|
||||
|
||||
1
docs/face-knn-source/SHA256SUMS
Normal file
1
docs/face-knn-source/SHA256SUMS
Normal file
|
|
@ -0,0 +1 @@
|
|||
e74bd672eebaa0f10028e25c0f7051e981cd16c2eeb9c529f513a6c412c11033 bin/face_knn
|
||||
0
docs/face-knn-source/bin/.gitkeep
Normal file
0
docs/face-knn-source/bin/.gitkeep
Normal file
BIN
docs/face-knn-source/bin/face_knn
Executable file
BIN
docs/face-knn-source/bin/face_knn
Executable file
Binary file not shown.
BIN
docs/face-knn-source/bin/face_recognition
Executable file
BIN
docs/face-knn-source/bin/face_recognition
Executable file
Binary file not shown.
BIN
docs/face-knn-source/bin/face_scan
Executable file
BIN
docs/face-knn-source/bin/face_scan
Executable file
Binary file not shown.
BIN
docs/face-knn-source/bin/test_knn_cpu
Executable file
BIN
docs/face-knn-source/bin/test_knn_cpu
Executable file
Binary file not shown.
5
docs/face-knn-source/include/face_config.h
Normal file
5
docs/face-knn-source/include/face_config.h
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
#pragma once
|
||||
|
||||
#ifndef FACE_CLUSTERING_ROOT
|
||||
#define FACE_CLUSTERING_ROOT "."
|
||||
#endif
|
||||
47
docs/face-knn-source/include/face_engine.h
Normal file
47
docs/face-knn-source/include/face_engine.h
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
struct Point {
|
||||
float x = 0.0f;
|
||||
float y = 0.0f;
|
||||
};
|
||||
|
||||
struct Face {
|
||||
float score = 0.0f;
|
||||
|
||||
float x1 = 0.0f;
|
||||
float y1 = 0.0f;
|
||||
float x2 = 0.0f;
|
||||
float y2 = 0.0f;
|
||||
|
||||
Point kps[5]{};
|
||||
|
||||
// SFace: embedding L2-normalizzato da 128 float32.
|
||||
std::vector<float> embedding;
|
||||
};
|
||||
|
||||
class FaceEngine {
|
||||
public:
|
||||
FaceEngine();
|
||||
~FaceEngine();
|
||||
|
||||
FaceEngine(const FaceEngine&) = delete;
|
||||
FaceEngine& operator=(const FaceEngine&) = delete;
|
||||
|
||||
bool init(
|
||||
const std::string& scrfd_model,
|
||||
const std::string& sface_model);
|
||||
|
||||
bool process_image(
|
||||
const std::string& image_path,
|
||||
std::vector<Face>& faces,
|
||||
int& image_width,
|
||||
int& image_height,
|
||||
int rotation_degrees = 0);
|
||||
|
||||
private:
|
||||
struct Impl;
|
||||
Impl* impl_;
|
||||
};
|
||||
121
docs/face-knn-source/include/knn_opencl.h
Normal file
121
docs/face-knn-source/include/knn_opencl.h
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
#ifndef FACE_CLUSTERING_KNN_OPENCL_H
|
||||
#define FACE_CLUSTERING_KNN_OPENCL_H
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace face_knn {
|
||||
|
||||
constexpr std::uint32_t invalid_index =
|
||||
0xffffffffu;
|
||||
|
||||
|
||||
struct Neighbor {
|
||||
float score = 0.0f;
|
||||
|
||||
std::uint32_t index =
|
||||
invalid_index;
|
||||
};
|
||||
|
||||
|
||||
struct SearchOptions {
|
||||
std::uint32_t dimension = 128;
|
||||
|
||||
std::uint32_t top_k = 1;
|
||||
|
||||
std::uint32_t lanes_per_query = 8;
|
||||
|
||||
std::size_t chunk_size = 50000;
|
||||
|
||||
std::string kernel_path;
|
||||
};
|
||||
|
||||
|
||||
struct SearchOutput {
|
||||
std::size_t query_count = 0;
|
||||
|
||||
std::size_t database_count = 0;
|
||||
|
||||
std::uint32_t dimension = 0;
|
||||
|
||||
std::uint32_t top_k = 0;
|
||||
|
||||
std::string backend;
|
||||
|
||||
std::string device_name;
|
||||
|
||||
double elapsed_ms = 0.0;
|
||||
|
||||
/*
|
||||
* Layout:
|
||||
*
|
||||
* neighbors[
|
||||
* query_index * top_k
|
||||
* + neighbor_position
|
||||
* ]
|
||||
*/
|
||||
std::vector<Neighbor> neighbors;
|
||||
};
|
||||
|
||||
|
||||
struct ValidationResult {
|
||||
std::size_t positions = 0;
|
||||
|
||||
std::size_t exact_index_matches = 0;
|
||||
|
||||
std::size_t decision_matches = 0;
|
||||
|
||||
float maximum_score_error = 0.0f;
|
||||
|
||||
bool exact_indices = false;
|
||||
|
||||
bool decisions_equal = false;
|
||||
};
|
||||
|
||||
|
||||
void validate_input(
|
||||
const std::vector<float>& database,
|
||||
const std::vector<float>& queries,
|
||||
std::size_t database_count,
|
||||
std::size_t query_count,
|
||||
const SearchOptions& options);
|
||||
|
||||
|
||||
SearchOutput search_cpu(
|
||||
const std::vector<float>& database,
|
||||
const std::vector<float>& queries,
|
||||
std::size_t database_count,
|
||||
std::size_t query_count,
|
||||
const SearchOptions& options);
|
||||
|
||||
|
||||
SearchOutput search_opencl(
|
||||
const std::vector<float>& database,
|
||||
const std::vector<float>& queries,
|
||||
std::size_t database_count,
|
||||
std::size_t query_count,
|
||||
const SearchOptions& options);
|
||||
|
||||
|
||||
ValidationResult compare_outputs(
|
||||
const SearchOutput& expected,
|
||||
const SearchOutput& actual,
|
||||
float score_tolerance,
|
||||
float recognition_threshold);
|
||||
|
||||
|
||||
std::vector<Neighbor> query_neighbors(
|
||||
const SearchOutput& output,
|
||||
std::size_t query_index);
|
||||
|
||||
|
||||
std::vector<Neighbor> exclude_neighbor(
|
||||
const std::vector<Neighbor>& neighbors,
|
||||
std::uint32_t excluded_index);
|
||||
|
||||
|
||||
} // namespace face_knn
|
||||
|
||||
#endif
|
||||
804
docs/face-knn-source/include/rknn_api.h
Normal file
804
docs/face-knn-source/include/rknn_api.h
Normal file
|
|
@ -0,0 +1,804 @@
|
|||
/****************************************************************************
|
||||
*
|
||||
* Copyright (c) 2017 - 2022 by Rockchip Corp. All rights reserved.
|
||||
*
|
||||
* The material in this file is confidential and contains trade secrets
|
||||
* of Rockchip Corporation. This is proprietary information owned by
|
||||
* Rockchip Corporation. No part of this work may be disclosed,
|
||||
* reproduced, copied, transmitted, or used in any way for any purpose,
|
||||
* without the express written permission of Rockchip Corporation.
|
||||
*
|
||||
*****************************************************************************/
|
||||
|
||||
|
||||
#ifndef _RKNN_API_H
|
||||
#define _RKNN_API_H
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
/*
|
||||
Definition of extended flag for rknn_init.
|
||||
*/
|
||||
/* set high priority context. */
|
||||
#define RKNN_FLAG_PRIOR_HIGH 0x00000000
|
||||
|
||||
/* set medium priority context */
|
||||
#define RKNN_FLAG_PRIOR_MEDIUM 0x00000001
|
||||
|
||||
/* set low priority context. */
|
||||
#define RKNN_FLAG_PRIOR_LOW 0x00000002
|
||||
|
||||
/* asynchronous mode.
|
||||
when enable, rknn_outputs_get will not block for too long because it directly retrieves the result of
|
||||
the previous frame which can increase the frame rate on single-threaded mode, but at the cost of
|
||||
rknn_outputs_get not retrieves the result of the current frame.
|
||||
in multi-threaded mode you do not need to turn this mode on. */
|
||||
#define RKNN_FLAG_ASYNC_MASK 0x00000004
|
||||
|
||||
/* collect performance mode.
|
||||
when enable, you can get detailed performance reports via rknn_query(ctx, RKNN_QUERY_PERF_DETAIL, ...),
|
||||
but it will reduce the frame rate. */
|
||||
#define RKNN_FLAG_COLLECT_PERF_MASK 0x00000008
|
||||
|
||||
/* allocate all memory in outside, includes weight/internal/inputs/outputs */
|
||||
#define RKNN_FLAG_MEM_ALLOC_OUTSIDE 0x00000010
|
||||
|
||||
/* weight sharing with the same network structure */
|
||||
#define RKNN_FLAG_SHARE_WEIGHT_MEM 0x00000020
|
||||
|
||||
/* send fence fd from outside */
|
||||
#define RKNN_FLAG_FENCE_IN_OUTSIDE 0x00000040
|
||||
|
||||
/* get fence fd from inside */
|
||||
#define RKNN_FLAG_FENCE_OUT_OUTSIDE 0x00000080
|
||||
|
||||
/* dummy init flag: could only get total_weight_size and total_internal_size by rknn_query*/
|
||||
#define RKNN_FLAG_COLLECT_MODEL_INFO_ONLY 0x00000100
|
||||
|
||||
/* allocate internal memory in outside */
|
||||
#define RKNN_FLAG_INTERNAL_ALLOC_OUTSIDE 0x00000200
|
||||
|
||||
/* set GPU as the preferred execution backend When the operator is not supported by the NPU */
|
||||
#define RKNN_FLAG_EXECUTE_FALLBACK_PRIOR_DEVICE_GPU 0x00000400
|
||||
|
||||
/* enable allocate sram type buffers */
|
||||
#define RKNN_FLAG_ENABLE_SRAM 0x00000800
|
||||
|
||||
/* sram type buffers are shared among different contexts */
|
||||
#define RKNN_FLAG_SHARE_SRAM 0x00001000
|
||||
|
||||
/* default nice -19, this flag can disable default priority */
|
||||
#define RKNN_FLAG_DISABLE_PROC_HIGH_PRIORITY 0x00002000
|
||||
|
||||
/* don't flush input buffer cache, the user must ensure that the input tensor has flushed the cache before calling rknn_run.
|
||||
!!! Don't use this flags when you call rknn_inputs_set() to set input data. */
|
||||
#define RKNN_FLAG_DISABLE_FLUSH_INPUT_MEM_CACHE 0x00004000
|
||||
|
||||
/* Don't invalid output buffer cache.
|
||||
Users cannot directly access output_mem->virt_addr,
|
||||
which will cause cache consistency problems.
|
||||
If you want to use output_mem->virt_addr,
|
||||
you must use rknn_mem_sync (ctx, mem, RKNN_MEMORY_SYNC_FROM_DEVICE) to flush the cache.
|
||||
This flags is generally used when the output data of the NPU is not accessed by the CPU,
|
||||
but is accessed by the GPU or RGA to reduce the time required to flush the cache.
|
||||
!!! Don't use this flags when you call rknn_outputs_get() to get output data.*/
|
||||
#define RKNN_FLAG_DISABLE_FLUSH_OUTPUT_MEM_CACHE 0x00008000
|
||||
|
||||
/* This flag is used when the model data buffer is allocated by NPU, and can be accessed by NPU directly. */
|
||||
#define RKNN_FLAG_MODEL_BUFFER_ZERO_COPY 0x00010000
|
||||
|
||||
/* This flag is a memory allocation flag, which is used in rknn_create_mem2() when no context is available. */
|
||||
#define RKNN_MEM_FLAG_ALLOC_NO_CONTEXT 0x00020000
|
||||
|
||||
|
||||
/*
|
||||
Error code returned by the RKNN API.
|
||||
*/
|
||||
#define RKNN_SUCC 0 /* execute succeed. */
|
||||
#define RKNN_ERR_FAIL -1 /* execute failed. */
|
||||
#define RKNN_ERR_TIMEOUT -2 /* execute timeout. */
|
||||
#define RKNN_ERR_DEVICE_UNAVAILABLE -3 /* device is unavailable. */
|
||||
#define RKNN_ERR_MALLOC_FAIL -4 /* memory malloc fail. */
|
||||
#define RKNN_ERR_PARAM_INVALID -5 /* parameter is invalid. */
|
||||
#define RKNN_ERR_MODEL_INVALID -6 /* model is invalid. */
|
||||
#define RKNN_ERR_CTX_INVALID -7 /* context is invalid. */
|
||||
#define RKNN_ERR_INPUT_INVALID -8 /* input is invalid. */
|
||||
#define RKNN_ERR_OUTPUT_INVALID -9 /* output is invalid. */
|
||||
#define RKNN_ERR_DEVICE_UNMATCH -10 /* the device is unmatch, please update rknn sdk
|
||||
and npu driver/firmware. */
|
||||
#define RKNN_ERR_INCOMPATILE_PRE_COMPILE_MODEL -11 /* This RKNN model use pre_compile mode, but not compatible with current driver. */
|
||||
#define RKNN_ERR_INCOMPATILE_OPTIMIZATION_LEVEL_VERSION -12 /* This RKNN model set optimization level, but not compatible with current driver. */
|
||||
#define RKNN_ERR_TARGET_PLATFORM_UNMATCH -13 /* This RKNN model set target platform, but not compatible with current platform. */
|
||||
|
||||
/*
|
||||
Definition for tensor
|
||||
*/
|
||||
#define RKNN_MAX_DIMS 16 /* maximum dimension of tensor. */
|
||||
#define RKNN_MAX_NUM_CHANNEL 15 /* maximum channel number of input tensor. */
|
||||
#define RKNN_MAX_NAME_LEN 256 /* maximum name lenth of tensor. */
|
||||
#define RKNN_MAX_DYNAMIC_SHAPE_NUM 512 /* maximum number of dynamic shape for each input. */
|
||||
|
||||
#ifdef __arm__
|
||||
typedef uint32_t rknn_context;
|
||||
#else
|
||||
typedef uint64_t rknn_context;
|
||||
#endif
|
||||
|
||||
|
||||
/*
|
||||
The query command for rknn_query
|
||||
*/
|
||||
typedef enum _rknn_query_cmd {
|
||||
RKNN_QUERY_IN_OUT_NUM = 0, /* query the number of input & output tensor. */
|
||||
RKNN_QUERY_INPUT_ATTR = 1, /* query the attribute of input tensor. */
|
||||
RKNN_QUERY_OUTPUT_ATTR = 2, /* query the attribute of output tensor. */
|
||||
RKNN_QUERY_PERF_DETAIL = 3, /* query the detail performance, need set
|
||||
RKNN_FLAG_COLLECT_PERF_MASK when call rknn_init,
|
||||
this query needs to be valid after rknn_outputs_get. */
|
||||
RKNN_QUERY_PERF_RUN = 4, /* query the time of run,
|
||||
this query needs to be valid after rknn_outputs_get. */
|
||||
RKNN_QUERY_SDK_VERSION = 5, /* query the sdk & driver version */
|
||||
|
||||
RKNN_QUERY_MEM_SIZE = 6, /* query the weight & internal memory size */
|
||||
RKNN_QUERY_CUSTOM_STRING = 7, /* query the custom string */
|
||||
|
||||
RKNN_QUERY_NATIVE_INPUT_ATTR = 8, /* query the attribute of native input tensor. */
|
||||
RKNN_QUERY_NATIVE_OUTPUT_ATTR = 9, /* query the attribute of native output tensor. */
|
||||
|
||||
RKNN_QUERY_NATIVE_NC1HWC2_INPUT_ATTR = 8, /* query the attribute of native input tensor. */
|
||||
RKNN_QUERY_NATIVE_NC1HWC2_OUTPUT_ATTR = 9, /* query the attribute of native output tensor. */
|
||||
|
||||
RKNN_QUERY_NATIVE_NHWC_INPUT_ATTR = 10, /* query the attribute of native input tensor. */
|
||||
RKNN_QUERY_NATIVE_NHWC_OUTPUT_ATTR = 11, /* query the attribute of native output tensor. */
|
||||
|
||||
RKNN_QUERY_DEVICE_MEM_INFO = 12, /* query the attribute of rknn memory information. */
|
||||
|
||||
RKNN_QUERY_INPUT_DYNAMIC_RANGE = 13, /* query the dynamic shape range of rknn input tensor. */
|
||||
RKNN_QUERY_CURRENT_INPUT_ATTR = 14, /* query the current shape of rknn input tensor, only valid for dynamic rknn model*/
|
||||
RKNN_QUERY_CURRENT_OUTPUT_ATTR = 15, /* query the current shape of rknn output tensor, only valid for dynamic rknn model*/
|
||||
|
||||
RKNN_QUERY_CURRENT_NATIVE_INPUT_ATTR = 16, /* query the current native shape of rknn input tensor, only valid for dynamic rknn model*/
|
||||
RKNN_QUERY_CURRENT_NATIVE_OUTPUT_ATTR = 17, /* query the current native shape of rknn output tensor, only valid for dynamic rknn model*/
|
||||
|
||||
|
||||
RKNN_QUERY_CMD_MAX
|
||||
} rknn_query_cmd;
|
||||
|
||||
/*
|
||||
the tensor data type.
|
||||
*/
|
||||
typedef enum _rknn_tensor_type {
|
||||
RKNN_TENSOR_FLOAT32 = 0, /* data type is float32. */
|
||||
RKNN_TENSOR_FLOAT16, /* data type is float16. */
|
||||
RKNN_TENSOR_INT8, /* data type is int8. */
|
||||
RKNN_TENSOR_UINT8, /* data type is uint8. */
|
||||
RKNN_TENSOR_INT16, /* data type is int16. */
|
||||
RKNN_TENSOR_UINT16, /* data type is uint16. */
|
||||
RKNN_TENSOR_INT32, /* data type is int32. */
|
||||
RKNN_TENSOR_UINT32, /* data type is uint32. */
|
||||
RKNN_TENSOR_INT64, /* data type is int64. */
|
||||
RKNN_TENSOR_BOOL,
|
||||
RKNN_TENSOR_INT4,
|
||||
RKNN_TENSOR_BFLOAT16,
|
||||
|
||||
RKNN_TENSOR_TYPE_MAX
|
||||
} rknn_tensor_type;
|
||||
|
||||
inline static const char* get_type_string(rknn_tensor_type type)
|
||||
{
|
||||
switch(type) {
|
||||
case RKNN_TENSOR_FLOAT32: return "FP32";
|
||||
case RKNN_TENSOR_FLOAT16: return "FP16";
|
||||
case RKNN_TENSOR_INT8: return "INT8";
|
||||
case RKNN_TENSOR_UINT8: return "UINT8";
|
||||
case RKNN_TENSOR_INT16: return "INT16";
|
||||
case RKNN_TENSOR_UINT16: return "UINT16";
|
||||
case RKNN_TENSOR_INT32: return "INT32";
|
||||
case RKNN_TENSOR_UINT32: return "UINT32";
|
||||
case RKNN_TENSOR_INT64: return "INT64";
|
||||
case RKNN_TENSOR_BOOL: return "BOOL";
|
||||
case RKNN_TENSOR_INT4: return "INT4";
|
||||
case RKNN_TENSOR_BFLOAT16: return "BF16";
|
||||
default: return "UNKNOW";
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
the quantitative type.
|
||||
*/
|
||||
typedef enum _rknn_tensor_qnt_type {
|
||||
RKNN_TENSOR_QNT_NONE = 0, /* none. */
|
||||
RKNN_TENSOR_QNT_DFP, /* dynamic fixed point. */
|
||||
RKNN_TENSOR_QNT_AFFINE_ASYMMETRIC, /* asymmetric affine. */
|
||||
|
||||
RKNN_TENSOR_QNT_MAX
|
||||
} rknn_tensor_qnt_type;
|
||||
|
||||
inline static const char* get_qnt_type_string(rknn_tensor_qnt_type type)
|
||||
{
|
||||
switch(type) {
|
||||
case RKNN_TENSOR_QNT_NONE: return "NONE";
|
||||
case RKNN_TENSOR_QNT_DFP: return "DFP";
|
||||
case RKNN_TENSOR_QNT_AFFINE_ASYMMETRIC: return "AFFINE";
|
||||
default: return "UNKNOW";
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
the tensor data format.
|
||||
*/
|
||||
typedef enum _rknn_tensor_format {
|
||||
RKNN_TENSOR_NCHW = 0, /* data format is NCHW. */
|
||||
RKNN_TENSOR_NHWC, /* data format is NHWC. */
|
||||
RKNN_TENSOR_NC1HWC2, /* data format is NC1HWC2. */
|
||||
RKNN_TENSOR_UNDEFINED,
|
||||
|
||||
RKNN_TENSOR_FORMAT_MAX
|
||||
} rknn_tensor_format;
|
||||
|
||||
/*
|
||||
the mode of running on target NPU core.
|
||||
*/
|
||||
typedef enum _rknn_core_mask {
|
||||
RKNN_NPU_CORE_AUTO = 0, /* default, run on NPU core randomly. */
|
||||
RKNN_NPU_CORE_0 = 1, /* run on NPU core 0. */
|
||||
RKNN_NPU_CORE_1 = 2, /* run on NPU core 1. */
|
||||
RKNN_NPU_CORE_2 = 4, /* run on NPU core 2. */
|
||||
RKNN_NPU_CORE_0_1 = RKNN_NPU_CORE_0 | RKNN_NPU_CORE_1, /* run on NPU core 0 and core 1. */
|
||||
RKNN_NPU_CORE_0_1_2 = RKNN_NPU_CORE_0_1 | RKNN_NPU_CORE_2, /* run on NPU core 0 and core 1 and core 2. */
|
||||
RKNN_NPU_CORE_ALL = 0xffff, /* auto choice, run on NPU cores depending on platform */
|
||||
|
||||
RKNN_NPU_CORE_UNDEFINED,
|
||||
} rknn_core_mask;
|
||||
|
||||
inline static const char* get_format_string(rknn_tensor_format fmt)
|
||||
{
|
||||
switch(fmt) {
|
||||
case RKNN_TENSOR_NCHW: return "NCHW";
|
||||
case RKNN_TENSOR_NHWC: return "NHWC";
|
||||
case RKNN_TENSOR_NC1HWC2: return "NC1HWC2";
|
||||
case RKNN_TENSOR_UNDEFINED: return "UNDEFINED";
|
||||
default: return "UNKNOW";
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
the information for RKNN_QUERY_IN_OUT_NUM.
|
||||
*/
|
||||
typedef struct _rknn_input_output_num {
|
||||
uint32_t n_input; /* the number of input. */
|
||||
uint32_t n_output; /* the number of output. */
|
||||
} rknn_input_output_num;
|
||||
|
||||
/*
|
||||
the information for RKNN_QUERY_INPUT_ATTR / RKNN_QUERY_OUTPUT_ATTR.
|
||||
*/
|
||||
typedef struct _rknn_tensor_attr {
|
||||
uint32_t index; /* input parameter, the index of input/output tensor,
|
||||
need set before call rknn_query. */
|
||||
|
||||
uint32_t n_dims; /* the number of dimensions. */
|
||||
uint32_t dims[RKNN_MAX_DIMS]; /* the dimensions array. */
|
||||
char name[RKNN_MAX_NAME_LEN]; /* the name of tensor. */
|
||||
|
||||
uint32_t n_elems; /* the number of elements. */
|
||||
uint32_t size; /* the bytes size of tensor. */
|
||||
|
||||
rknn_tensor_format fmt; /* the data format of tensor. */
|
||||
rknn_tensor_type type; /* the data type of tensor. */
|
||||
rknn_tensor_qnt_type qnt_type; /* the quantitative type of tensor. */
|
||||
int8_t fl; /* fractional length for RKNN_TENSOR_QNT_DFP. */
|
||||
int32_t zp; /* zero point for RKNN_TENSOR_QNT_AFFINE_ASYMMETRIC. */
|
||||
float scale; /* scale for RKNN_TENSOR_QNT_AFFINE_ASYMMETRIC. */
|
||||
|
||||
uint32_t w_stride; /* the stride of tensor along the width dimention of input,
|
||||
Note: it is read-only, 0 means equal to width. */
|
||||
uint32_t size_with_stride; /* the bytes size of tensor with stride. */
|
||||
|
||||
uint8_t pass_through; /* pass through mode, for rknn_set_io_mem interface.
|
||||
if TRUE, the buf data is passed directly to the input node of the rknn model
|
||||
without any conversion. the following variables do not need to be set.
|
||||
if FALSE, the buf data is converted into an input consistent with the model
|
||||
according to the following type and fmt. so the following variables
|
||||
need to be set.*/
|
||||
uint32_t h_stride; /* the stride along the height dimention of input,
|
||||
Note: it is write-only, if it was set to 0, h_stride = height. */
|
||||
} rknn_tensor_attr;
|
||||
|
||||
typedef struct _rknn_input_range {
|
||||
uint32_t index; /* input parameter, the index of input/output tensor,
|
||||
need set before call rknn_query. */
|
||||
uint32_t shape_number; /* the number of shape. */
|
||||
rknn_tensor_format fmt; /* the data format of tensor. */
|
||||
char name[RKNN_MAX_NAME_LEN]; /* the name of tensor. */
|
||||
uint32_t dyn_range[RKNN_MAX_DYNAMIC_SHAPE_NUM][RKNN_MAX_DIMS]; /* the dynamic input dimensions range. */
|
||||
uint32_t n_dims; /* the number of dimensions. */
|
||||
|
||||
} rknn_input_range;
|
||||
|
||||
/*
|
||||
the information for RKNN_QUERY_PERF_DETAIL.
|
||||
*/
|
||||
typedef struct _rknn_perf_detail {
|
||||
char* perf_data; /* the string pointer of perf detail. don't need free it by user. */
|
||||
uint64_t data_len; /* the string length. */
|
||||
} rknn_perf_detail;
|
||||
|
||||
/*
|
||||
the information for RKNN_QUERY_PERF_RUN.
|
||||
*/
|
||||
typedef struct _rknn_perf_run {
|
||||
int64_t run_duration; /* real inference time (us) */
|
||||
} rknn_perf_run;
|
||||
|
||||
/*
|
||||
the information for RKNN_QUERY_SDK_VERSION.
|
||||
*/
|
||||
typedef struct _rknn_sdk_version {
|
||||
char api_version[256]; /* the version of rknn api. */
|
||||
char drv_version[256]; /* the version of rknn driver. */
|
||||
} rknn_sdk_version;
|
||||
|
||||
/*
|
||||
the information for RKNN_QUERY_MEM_SIZE.
|
||||
*/
|
||||
typedef struct _rknn_mem_size {
|
||||
uint32_t total_weight_size; /* the weight memory size */
|
||||
uint32_t total_internal_size; /* the internal memory size, exclude inputs/outputs */
|
||||
uint64_t total_dma_allocated_size; /* total dma memory allocated size */
|
||||
uint32_t total_sram_size; /* total system sram size reserved for rknn */
|
||||
uint32_t free_sram_size; /* free system sram size reserved for rknn */
|
||||
uint32_t reserved[10]; /* reserved */
|
||||
} rknn_mem_size;
|
||||
|
||||
/*
|
||||
the information for RKNN_QUERY_CUSTOM_STRING.
|
||||
*/
|
||||
typedef struct _rknn_custom_string {
|
||||
char string[1024]; /* the string of custom, lengths max to 1024 bytes */
|
||||
} rknn_custom_string;
|
||||
|
||||
/*
|
||||
The flags of rknn_tensor_mem.
|
||||
*/
|
||||
typedef enum _rknn_tensor_mem_flags {
|
||||
RKNN_TENSOR_MEMORY_FLAGS_ALLOC_INSIDE = 1, /*Used to mark in rknn_destroy_mem() whether it is necessary to release the "mem" pointer itself.
|
||||
If the flag RKNN_TENSOR_MEMORY_FLAGS_ALLOC_INSIDE is set, rknn_destroy_mem() will call free(mem).*/
|
||||
RKNN_TENSOR_MEMORY_FLAGS_FROM_FD = 2, /*Used to mark in rknn_create_mem_from_fd() whether it is necessary to release the "mem" pointer itself.
|
||||
If the flag RKNN_TENSOR_MEMORY_FLAGS_FROM_FD is set, rknn_destroy_mem() will call free(mem).*/
|
||||
RKNN_TENSOR_MEMORY_FLAGS_FROM_PHYS = 3, /*Used to mark in rknn_create_mem_from_phys() whether it is necessary to release the "mem" pointer itself.
|
||||
If the flag RKNN_TENSOR_MEMORY_FLAGS_FROM_PHYS is set, rknn_destroy_mem() will call free(mem).*/
|
||||
RKNN_TENSOR_MEMORY_FLAGS_UNKNOWN
|
||||
} rknn_tensor_mem_flags;
|
||||
|
||||
/*
|
||||
The mode to allocate rknn memory.
|
||||
*/
|
||||
typedef enum _rknn_mem_alloc_flags {
|
||||
RKNN_FLAG_MEMORY_FLAGS_DEFAULT = 0 << 0, /* Same with RKNN_FLAG_MEMORY_CACHEABLE */
|
||||
RKNN_FLAG_MEMORY_CACHEABLE = 1 << 0, /* Create Cacheable memory. */
|
||||
RKNN_FLAG_MEMORY_NON_CACHEABLE = 1 << 1, /* Create NON-Cacheable memory. */
|
||||
RKNN_FLAG_MEMORY_TRY_ALLOC_SRAM = 1 << 2, /* Try to allocate memory in SRAM if possible. if SRAM is not enough, allocate rest memory in DRAM. */
|
||||
} rknn_mem_alloc_flags;
|
||||
|
||||
/*
|
||||
The mode to sync cacheable rknn memory.
|
||||
*/
|
||||
typedef enum _rknn_mem_sync_mode {
|
||||
RKNN_MEMORY_SYNC_TO_DEVICE = 0x1, /* the mode used for consistency of device access after CPU accesses data. */
|
||||
RKNN_MEMORY_SYNC_FROM_DEVICE = 0x2, /* the mode used for consistency of CPU access after device accesses data. */
|
||||
RKNN_MEMORY_SYNC_BIDIRECTIONAL = RKNN_MEMORY_SYNC_TO_DEVICE | RKNN_MEMORY_SYNC_FROM_DEVICE, /* the mode used for consistency of data access
|
||||
between device and CPU in both directions. */
|
||||
} rknn_mem_sync_mode;
|
||||
|
||||
/*
|
||||
the memory information of tensor.
|
||||
*/
|
||||
typedef struct _rknn_tensor_memory {
|
||||
void* virt_addr; /* the virtual address of tensor buffer. */
|
||||
uint64_t phys_addr; /* the physical address of tensor buffer. */
|
||||
int32_t fd; /* the fd of tensor buffer. */
|
||||
int32_t offset; /* indicates the offset of the memory. */
|
||||
uint32_t size; /* the size of tensor buffer. */
|
||||
uint32_t flags; /* the flags of tensor buffer, reserved */
|
||||
void * priv_data; /* the private data of tensor buffer. */
|
||||
} rknn_tensor_mem;
|
||||
|
||||
/*
|
||||
the input information for rknn_input_set.
|
||||
*/
|
||||
typedef struct _rknn_input {
|
||||
uint32_t index; /* the input index. */
|
||||
void* buf; /* the input buf for index. */
|
||||
uint32_t size; /* the size of input buf. */
|
||||
uint8_t pass_through; /* pass through mode.
|
||||
if TRUE, the buf data is passed directly to the input node of the rknn model
|
||||
without any conversion. the following variables do not need to be set.
|
||||
if FALSE, the buf data is converted into an input consistent with the model
|
||||
according to the following type and fmt. so the following variables
|
||||
need to be set.*/
|
||||
rknn_tensor_type type; /* the data type of input buf. */
|
||||
rknn_tensor_format fmt; /* the data format of input buf.
|
||||
currently the internal input format of NPU is NCHW by default.
|
||||
so entering NCHW data can avoid the format conversion in the driver. */
|
||||
} rknn_input;
|
||||
|
||||
/*
|
||||
the output information for rknn_outputs_get.
|
||||
*/
|
||||
typedef struct _rknn_output {
|
||||
uint8_t want_float; /* want transfer output data to float */
|
||||
uint8_t is_prealloc; /* whether buf is pre-allocated.
|
||||
if TRUE, the following variables need to be set.
|
||||
if FALSE, the following variables do not need to be set. */
|
||||
uint32_t index; /* the output index. */
|
||||
void* buf; /* the output buf for index.
|
||||
when is_prealloc = FALSE and rknn_outputs_release called,
|
||||
this buf pointer will be free and don't use it anymore. */
|
||||
uint32_t size; /* the size of output buf. */
|
||||
} rknn_output;
|
||||
|
||||
/*
|
||||
the extend information for rknn_init.
|
||||
*/
|
||||
typedef struct _rknn_init_extend {
|
||||
rknn_context ctx; /* rknn context */
|
||||
int32_t real_model_offset; /* real rknn model file offset, only valid when init context with rknn file path and zero-copy model model */
|
||||
uint32_t real_model_size; /* real rknn model file size, only valid when init context with rknn file path and zero-copy model model */
|
||||
int32_t model_buffer_fd; /* the fd of model buffer. */
|
||||
uint32_t model_buffer_flags; /* the flags of model_buffer */
|
||||
uint8_t reserved[112]; /* reserved */
|
||||
} rknn_init_extend;
|
||||
|
||||
/*
|
||||
the extend information for rknn_run.
|
||||
*/
|
||||
typedef struct _rknn_run_extend {
|
||||
uint64_t frame_id; /* output parameter, indicate current frame id of run. */
|
||||
int32_t non_block; /* block flag of run, 0 is block else 1 is non block */
|
||||
int32_t timeout_ms; /* timeout for block mode, in milliseconds */
|
||||
int32_t fence_fd; /* fence fd from other unit */
|
||||
} rknn_run_extend;
|
||||
|
||||
/*
|
||||
the extend information for rknn_outputs_get.
|
||||
*/
|
||||
typedef struct _rknn_output_extend {
|
||||
uint64_t frame_id; /* output parameter, indicate the frame id of outputs, corresponds to
|
||||
struct rknn_run_extend.frame_id.*/
|
||||
} rknn_output_extend;
|
||||
|
||||
|
||||
/* rknn_init
|
||||
|
||||
initial the context and load the rknn model.
|
||||
|
||||
input:
|
||||
rknn_context* context the pointer of context handle.
|
||||
void* model if size > 0, pointer to the rknn model, if size = 0, filepath to the rknn model.
|
||||
uint32_t size the size of rknn model.
|
||||
uint32_t flag extend flag, see the define of RKNN_FLAG_XXX_XXX.
|
||||
rknn_init_extend* extend the extend information of init.
|
||||
return:
|
||||
int error code.
|
||||
*/
|
||||
int rknn_init(rknn_context* context, void* model, uint32_t size, uint32_t flag, rknn_init_extend* extend);
|
||||
|
||||
/* rknn_dup_context
|
||||
|
||||
initial the context and load the rknn model.
|
||||
|
||||
input:
|
||||
rknn_context* context_in the pointer of context in handle.
|
||||
rknn_context* context_out the pointer of context out handle.
|
||||
return:
|
||||
int error code.
|
||||
*/
|
||||
int rknn_dup_context(rknn_context* context_in, rknn_context* context_out);
|
||||
|
||||
/* rknn_destroy
|
||||
|
||||
unload the rknn model and destroy the context.
|
||||
|
||||
input:
|
||||
rknn_context context the handle of context.
|
||||
return:
|
||||
int error code.
|
||||
*/
|
||||
int rknn_destroy(rknn_context context);
|
||||
|
||||
|
||||
/* rknn_query
|
||||
|
||||
query the information about model or others. see rknn_query_cmd.
|
||||
|
||||
input:
|
||||
rknn_context context the handle of context.
|
||||
rknn_query_cmd cmd the command of query.
|
||||
void* info the buffer point of information.
|
||||
uint32_t size the size of information.
|
||||
return:
|
||||
int error code.
|
||||
*/
|
||||
int rknn_query(rknn_context context, rknn_query_cmd cmd, void* info, uint32_t size);
|
||||
|
||||
|
||||
/* rknn_inputs_set
|
||||
|
||||
set inputs information by input index of rknn model.
|
||||
inputs information see rknn_input.
|
||||
|
||||
input:
|
||||
rknn_context context the handle of context.
|
||||
uint32_t n_inputs the number of inputs.
|
||||
rknn_input inputs[] the arrays of inputs information, see rknn_input.
|
||||
return:
|
||||
int error code
|
||||
*/
|
||||
int rknn_inputs_set(rknn_context context, uint32_t n_inputs, rknn_input inputs[]);
|
||||
|
||||
/*
|
||||
rknn_set_batch_core_num
|
||||
|
||||
set rknn batch core_num.
|
||||
|
||||
input:
|
||||
rknn_context context the handle of context.
|
||||
int core_num the core number.
|
||||
return:
|
||||
int error code.
|
||||
|
||||
*/
|
||||
int rknn_set_batch_core_num(rknn_context context, int core_num);
|
||||
|
||||
/* rknn_set_core_mask
|
||||
|
||||
set the core mask for the model.(only supported on multi-core NPU platform)
|
||||
|
||||
RKNN_NPU_CORE_AUTO: auto mode, default value
|
||||
RKNN_NPU_CORE_0: core 0 mode
|
||||
RKNN_NPU_CORE_1: core 1 mode
|
||||
RKNN_NPU_CORE_2: core 2 mode
|
||||
RKNN_NPU_CORE_0_1: combine core 0/1 mode
|
||||
RKNN_NPU_CORE_0_1_2: combine core 0/1/2 mode
|
||||
RKNN_NPU_CORE_ALL: auto mode, select multiple npu cores to run depending on platform
|
||||
|
||||
|
||||
input:
|
||||
rknn_context context the handle of context.
|
||||
rknn_core_mask core_mask the core mask.
|
||||
return:
|
||||
int error code.
|
||||
*/
|
||||
int rknn_set_core_mask(rknn_context context, rknn_core_mask core_mask);
|
||||
|
||||
/* rknn_run
|
||||
|
||||
run the model to execute inference.
|
||||
|
||||
input:
|
||||
rknn_context context the handle of context.
|
||||
rknn_run_extend* extend the extend information of run.
|
||||
return:
|
||||
int error code.
|
||||
*/
|
||||
int rknn_run(rknn_context context, rknn_run_extend* extend);
|
||||
|
||||
|
||||
/* rknn_wait
|
||||
|
||||
wait the model after execute inference.
|
||||
|
||||
input:
|
||||
rknn_context context the handle of context.
|
||||
rknn_run_extend* extend the extend information of run.
|
||||
return:
|
||||
int error code.
|
||||
*/
|
||||
int rknn_wait(rknn_context context, rknn_run_extend* extend);
|
||||
|
||||
|
||||
/* rknn_outputs_get
|
||||
|
||||
wait the inference to finish and get the outputs.
|
||||
this function will block until inference finish.
|
||||
the results will set to outputs[].
|
||||
|
||||
input:
|
||||
rknn_context context the handle of context.
|
||||
uint32_t n_outputs the number of outputs.
|
||||
rknn_output outputs[] the arrays of output, see rknn_output.
|
||||
rknn_output_extend* the extend information of output.
|
||||
return:
|
||||
int error code.
|
||||
*/
|
||||
int rknn_outputs_get(rknn_context context, uint32_t n_outputs, rknn_output outputs[], rknn_output_extend* extend);
|
||||
|
||||
|
||||
/* rknn_outputs_release
|
||||
|
||||
release the outputs that get by rknn_outputs_get.
|
||||
after called, the rknn_output[x].buf get from rknn_outputs_get will
|
||||
also be free when rknn_output[x].is_prealloc = FALSE.
|
||||
|
||||
input:
|
||||
rknn_context context the handle of context.
|
||||
uint32_t n_ouputs the number of outputs.
|
||||
rknn_output outputs[] the arrays of output.
|
||||
return:
|
||||
int error code
|
||||
*/
|
||||
int rknn_outputs_release(rknn_context context, uint32_t n_ouputs, rknn_output outputs[]);
|
||||
|
||||
|
||||
/* new api for zero copy */
|
||||
|
||||
/* rknn_create_mem_from_phys (memory allocated outside)
|
||||
|
||||
initialize tensor memory from physical address.
|
||||
|
||||
input:
|
||||
rknn_context ctx the handle of context.
|
||||
uint64_t phys_addr physical address.
|
||||
void *virt_addr virtual address.
|
||||
uint32_t size the size of tensor buffer.
|
||||
return:
|
||||
rknn_tensor_mem the pointer of tensor memory information.
|
||||
*/
|
||||
rknn_tensor_mem* rknn_create_mem_from_phys(rknn_context ctx, uint64_t phys_addr, void *virt_addr, uint32_t size);
|
||||
|
||||
|
||||
/* rknn_create_mem_from_fd (memory allocated outside)
|
||||
|
||||
initialize tensor memory from file description.
|
||||
|
||||
input:
|
||||
rknn_context ctx the handle of context.
|
||||
int32_t fd file description.
|
||||
void *virt_addr virtual address.
|
||||
uint32_t size the size of tensor buffer.
|
||||
int32_t offset indicates the offset of the memory (virt_addr without offset).
|
||||
return:
|
||||
rknn_tensor_mem the pointer of tensor memory information.
|
||||
*/
|
||||
rknn_tensor_mem* rknn_create_mem_from_fd(rknn_context ctx, int32_t fd, void *virt_addr, uint32_t size, int32_t offset);
|
||||
|
||||
|
||||
/* rknn_create_mem_from_mb_blk (memory allocated outside)
|
||||
|
||||
create tensor memory from mb_blk.
|
||||
|
||||
input:
|
||||
rknn_context ctx the handle of context.
|
||||
void *mb_blk mb_blk allocate from system api.
|
||||
int32_t offset indicates the offset of the memory.
|
||||
return:
|
||||
rknn_tensor_mem the pointer of tensor memory information.
|
||||
*/
|
||||
rknn_tensor_mem* rknn_create_mem_from_mb_blk(rknn_context ctx, void *mb_blk, int32_t offset);
|
||||
|
||||
|
||||
/* rknn_create_mem (memory allocated inside)
|
||||
|
||||
create tensor memory.
|
||||
|
||||
input:
|
||||
rknn_context ctx the handle of context.
|
||||
uint32_t size the size of tensor buffer.
|
||||
return:
|
||||
rknn_tensor_mem the pointer of tensor memory information.
|
||||
*/
|
||||
rknn_tensor_mem* rknn_create_mem(rknn_context ctx, uint32_t size);
|
||||
|
||||
/* rknn_create_mem2 (memory allocated inside)
|
||||
|
||||
create tensor memory.
|
||||
|
||||
input:
|
||||
rknn_context ctx the handle of context.
|
||||
uint64_t size the size of tensor buffer.
|
||||
uint64_t alloc_flags memory allocation flags.
|
||||
return:
|
||||
rknn_tensor_mem the pointer of tensor memory information.
|
||||
*/
|
||||
rknn_tensor_mem* rknn_create_mem2(rknn_context ctx, uint64_t size, uint64_t alloc_flags);
|
||||
|
||||
/* rknn_destroy_mem (support allocate inside and outside)
|
||||
|
||||
destroy tensor memory.
|
||||
|
||||
input:
|
||||
rknn_context ctx the handle of context.
|
||||
rknn_tensor_mem *mem the pointer of tensor memory information.
|
||||
return:
|
||||
int error code
|
||||
*/
|
||||
int rknn_destroy_mem(rknn_context ctx, rknn_tensor_mem *mem);
|
||||
|
||||
|
||||
/* rknn_set_weight_mem
|
||||
|
||||
set the weight memory.
|
||||
|
||||
input:
|
||||
rknn_context ctx the handle of context.
|
||||
rknn_tensor_mem *mem the array of tensor memory information
|
||||
return:
|
||||
int error code.
|
||||
*/
|
||||
int rknn_set_weight_mem(rknn_context ctx, rknn_tensor_mem *mem);
|
||||
|
||||
|
||||
/* rknn_set_internal_mem
|
||||
|
||||
set the internal memory.
|
||||
|
||||
input:
|
||||
rknn_context ctx the handle of context.
|
||||
rknn_tensor_mem *mem the array of tensor memory information
|
||||
return:
|
||||
int error code.
|
||||
*/
|
||||
int rknn_set_internal_mem(rknn_context ctx, rknn_tensor_mem *mem);
|
||||
|
||||
|
||||
/* rknn_set_io_mem
|
||||
|
||||
set the input and output tensors buffer.
|
||||
|
||||
input:
|
||||
rknn_context ctx the handle of context.
|
||||
rknn_tensor_mem *mem the array of tensor memory information.
|
||||
rknn_tensor_attr *attr the attribute of input or output tensor buffer.
|
||||
return:
|
||||
int error code.
|
||||
*/
|
||||
int rknn_set_io_mem(rknn_context ctx, rknn_tensor_mem *mem, rknn_tensor_attr *attr);
|
||||
|
||||
/* rknn_set_input_shape(deprecated)
|
||||
|
||||
set the input tensor shape (only valid for dynamic shape rknn model).
|
||||
|
||||
input:
|
||||
rknn_context ctx the handle of context.
|
||||
rknn_tensor_attr *attr the attribute of input or output tensor buffer.
|
||||
return:
|
||||
int error code.
|
||||
*/
|
||||
int rknn_set_input_shape(rknn_context ctx, rknn_tensor_attr* attr);
|
||||
|
||||
/* rknn_set_input_shapes
|
||||
|
||||
set all the input tensor shapes. graph will run under current set of input shapes after rknn_set_input_shapes.(only valid for dynamic shape rknn model).
|
||||
|
||||
input:
|
||||
rknn_context ctx the handle of context.
|
||||
uint32_t n_inputs the number of inputs.
|
||||
rknn_tensor_attr attr[] the attribute array of all input tensors.
|
||||
return:
|
||||
int error code.
|
||||
*/
|
||||
int rknn_set_input_shapes(rknn_context ctx, uint32_t n_inputs, rknn_tensor_attr attr[]);
|
||||
|
||||
/* rknn_mem_sync
|
||||
|
||||
sync cacheable rknn memory when both cpu and device access data.
|
||||
|
||||
input:
|
||||
rknn_context context the handle of context.
|
||||
rknn_tensor_mem *mem the pointer of tensor memory information.
|
||||
rknn_mem_sync_mode mode the mode of sync cache.
|
||||
return:
|
||||
int error code.
|
||||
*/
|
||||
int rknn_mem_sync(rknn_context context, rknn_tensor_mem* mem, rknn_mem_sync_mode mode);
|
||||
|
||||
#ifdef __cplusplus
|
||||
} //extern "C"
|
||||
#endif
|
||||
|
||||
#endif //_RKNN_API_H
|
||||
7762
docs/face-knn-source/include/stb_image.h
Normal file
7762
docs/face-knn-source/include/stb_image.h
Normal file
File diff suppressed because it is too large
Load diff
192
docs/face-knn-source/kernels/knn_float4_topk.cl
Normal file
192
docs/face-knn-source/kernels/knn_float4_topk.cl
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
|
||||
#ifndef DIM
|
||||
#define DIM 128
|
||||
#endif
|
||||
|
||||
#ifndef TOPK
|
||||
#define TOPK 32
|
||||
#endif
|
||||
|
||||
#ifndef QUERY_COUNT
|
||||
#define QUERY_COUNT 1
|
||||
#endif
|
||||
|
||||
/*
|
||||
* Numero di work-item che collaborano per ogni query.
|
||||
*/
|
||||
#ifndef LANES_PER_QUERY
|
||||
#define LANES_PER_QUERY 8
|
||||
#endif
|
||||
|
||||
/*
|
||||
* Ogni work-group contiene 8 work-item.
|
||||
*
|
||||
* Ogni work-item calcola un TOPK locale.
|
||||
* Poi il work-item 0 fonde gli 8 TOPK locali.
|
||||
*/
|
||||
|
||||
__kernel void knn(
|
||||
__global const float *db,
|
||||
__global const float *queries,
|
||||
__global uint *indices,
|
||||
__global float *values,
|
||||
uint n)
|
||||
{
|
||||
uint lane = get_local_id(0);
|
||||
uint q = get_group_id(0);
|
||||
|
||||
if (q >= QUERY_COUNT)
|
||||
return;
|
||||
|
||||
/*
|
||||
* TOPK locale per ogni work-item.
|
||||
*/
|
||||
float best_score[TOPK];
|
||||
uint best_index[TOPK];
|
||||
|
||||
for (uint k = 0; k < TOPK; ++k) {
|
||||
best_score[k] = -3.402823466e+38f;
|
||||
best_index[k] = 0xffffffff;
|
||||
}
|
||||
|
||||
__global const float *query =
|
||||
&queries[q * DIM];
|
||||
|
||||
/*
|
||||
* Ogni lane analizza una parte del database.
|
||||
*
|
||||
* Esempio con 8 lane:
|
||||
*
|
||||
* lane 0 -> 0, 8, 16, 24, ...
|
||||
* lane 1 -> 1, 9, 17, 25, ...
|
||||
* ...
|
||||
* lane 7 -> 7, 15, 23, 31, ...
|
||||
*/
|
||||
for (uint i = lane; i < n; i += LANES_PER_QUERY) {
|
||||
|
||||
__global const float *v =
|
||||
&db[i * DIM];
|
||||
|
||||
float s = 0.0f;
|
||||
|
||||
for (uint j = 0; j < DIM; ++j)
|
||||
s += v[j] * query[j];
|
||||
|
||||
if (s <= best_score[TOPK - 1])
|
||||
continue;
|
||||
|
||||
int pos = TOPK - 1;
|
||||
|
||||
while (pos > 0 &&
|
||||
s > best_score[pos - 1]) {
|
||||
--pos;
|
||||
}
|
||||
|
||||
for (int k = TOPK - 1; k > pos; --k) {
|
||||
best_score[k] =
|
||||
best_score[k - 1];
|
||||
|
||||
best_index[k] =
|
||||
best_index[k - 1];
|
||||
}
|
||||
|
||||
best_score[pos] = s;
|
||||
best_index[pos] = i;
|
||||
}
|
||||
|
||||
/*
|
||||
* TOPK locali nella local memory.
|
||||
*
|
||||
* 8 lane × 32 elementi.
|
||||
*/
|
||||
__local float local_scores[LANES_PER_QUERY][TOPK];
|
||||
__local uint local_indices[LANES_PER_QUERY][TOPK];
|
||||
|
||||
for (uint k = 0; k < TOPK; ++k) {
|
||||
local_scores[lane][k] = best_score[k];
|
||||
local_indices[lane][k] = best_index[k];
|
||||
}
|
||||
|
||||
/*
|
||||
* Tutti i lane devono aver scritto
|
||||
* la local memory prima della riduzione.
|
||||
*/
|
||||
barrier(CLK_LOCAL_MEM_FENCE);
|
||||
|
||||
/*
|
||||
* Solo lane 0 esegue il merge finale.
|
||||
*/
|
||||
if (lane == 0) {
|
||||
|
||||
float final_score[TOPK];
|
||||
uint final_index[TOPK];
|
||||
|
||||
for (uint k = 0; k < TOPK; ++k) {
|
||||
final_score[k] =
|
||||
-3.402823466e+38f;
|
||||
|
||||
final_index[k] =
|
||||
0xffffffff;
|
||||
}
|
||||
|
||||
/*
|
||||
* Fonde gli 8 TOPK locali.
|
||||
*
|
||||
* Al massimo 8 × 32 = 256 candidati.
|
||||
*/
|
||||
for (uint l = 0;
|
||||
l < LANES_PER_QUERY;
|
||||
++l) {
|
||||
|
||||
for (uint k = 0;
|
||||
k < TOPK;
|
||||
++k) {
|
||||
|
||||
float s =
|
||||
local_scores[l][k];
|
||||
|
||||
uint idx =
|
||||
local_indices[l][k];
|
||||
|
||||
if (idx == 0xffffffff)
|
||||
continue;
|
||||
|
||||
if (s <= final_score[TOPK - 1])
|
||||
continue;
|
||||
|
||||
int pos = TOPK - 1;
|
||||
|
||||
while (pos > 0 &&
|
||||
s > final_score[pos - 1]) {
|
||||
--pos;
|
||||
}
|
||||
|
||||
for (int p = TOPK - 1;
|
||||
p > pos;
|
||||
--p) {
|
||||
|
||||
final_score[p] =
|
||||
final_score[p - 1];
|
||||
|
||||
final_index[p] =
|
||||
final_index[p - 1];
|
||||
}
|
||||
|
||||
final_score[pos] = s;
|
||||
final_index[pos] = idx;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Risultato finale della query.
|
||||
*/
|
||||
for (uint k = 0; k < TOPK; ++k) {
|
||||
indices[q * TOPK + k] =
|
||||
final_index[k];
|
||||
|
||||
values[q * TOPK + k] =
|
||||
final_score[k];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1
docs/face-knn-source/kernels/knn_float4_topk.cl.sha256
Normal file
1
docs/face-knn-source/kernels/knn_float4_topk.cl.sha256
Normal file
|
|
@ -0,0 +1 @@
|
|||
f1979b6aa6d24633c6ac261fa66204e7f714a398091c87552712c43f8bb29467 knn_float4_topk.cl
|
||||
181
docs/face-knn-source/kernels/knn_reference.cl
Normal file
181
docs/face-knn-source/kernels/knn_reference.cl
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
|
||||
#define DIM 128
|
||||
#define TOPK 32
|
||||
|
||||
/*
|
||||
* Numero di work-item che collaborano per ogni query.
|
||||
*/
|
||||
#define LANES_PER_QUERY 8
|
||||
|
||||
/*
|
||||
* Ogni work-group contiene 8 work-item.
|
||||
*
|
||||
* Ogni work-item calcola un TOPK locale.
|
||||
* Poi il work-item 0 fonde gli 8 TOPK locali.
|
||||
*/
|
||||
|
||||
__kernel void knn(
|
||||
__global const float *db,
|
||||
__global const float *queries,
|
||||
__global uint *indices,
|
||||
__global float *values,
|
||||
uint n)
|
||||
{
|
||||
uint lane = get_local_id(0);
|
||||
uint q = get_group_id(0);
|
||||
|
||||
if (q >= 256)
|
||||
return;
|
||||
|
||||
/*
|
||||
* TOPK locale per ogni work-item.
|
||||
*/
|
||||
float best_score[TOPK];
|
||||
uint best_index[TOPK];
|
||||
|
||||
for (uint k = 0; k < TOPK; ++k) {
|
||||
best_score[k] = -3.402823466e+38f;
|
||||
best_index[k] = 0xffffffff;
|
||||
}
|
||||
|
||||
__global const float *query =
|
||||
&queries[q * DIM];
|
||||
|
||||
/*
|
||||
* Ogni lane analizza una parte del database.
|
||||
*
|
||||
* Esempio con 8 lane:
|
||||
*
|
||||
* lane 0 -> 0, 8, 16, 24, ...
|
||||
* lane 1 -> 1, 9, 17, 25, ...
|
||||
* ...
|
||||
* lane 7 -> 7, 15, 23, 31, ...
|
||||
*/
|
||||
for (uint i = lane; i < n; i += LANES_PER_QUERY) {
|
||||
|
||||
__global const float *v =
|
||||
&db[i * DIM];
|
||||
|
||||
float s = 0.0f;
|
||||
|
||||
for (uint j = 0; j < DIM; ++j)
|
||||
s += v[j] * query[j];
|
||||
|
||||
if (s <= best_score[TOPK - 1])
|
||||
continue;
|
||||
|
||||
int pos = TOPK - 1;
|
||||
|
||||
while (pos > 0 &&
|
||||
s > best_score[pos - 1]) {
|
||||
--pos;
|
||||
}
|
||||
|
||||
for (int k = TOPK - 1; k > pos; --k) {
|
||||
best_score[k] =
|
||||
best_score[k - 1];
|
||||
|
||||
best_index[k] =
|
||||
best_index[k - 1];
|
||||
}
|
||||
|
||||
best_score[pos] = s;
|
||||
best_index[pos] = i;
|
||||
}
|
||||
|
||||
/*
|
||||
* TOPK locali nella local memory.
|
||||
*
|
||||
* 8 lane × 32 elementi.
|
||||
*/
|
||||
__local float local_scores[LANES_PER_QUERY][TOPK];
|
||||
__local uint local_indices[LANES_PER_QUERY][TOPK];
|
||||
|
||||
for (uint k = 0; k < TOPK; ++k) {
|
||||
local_scores[lane][k] = best_score[k];
|
||||
local_indices[lane][k] = best_index[k];
|
||||
}
|
||||
|
||||
/*
|
||||
* Tutti i lane devono aver scritto
|
||||
* la local memory prima della riduzione.
|
||||
*/
|
||||
barrier(CLK_LOCAL_MEM_FENCE);
|
||||
|
||||
/*
|
||||
* Solo lane 0 esegue il merge finale.
|
||||
*/
|
||||
if (lane == 0) {
|
||||
|
||||
float final_score[TOPK];
|
||||
uint final_index[TOPK];
|
||||
|
||||
for (uint k = 0; k < TOPK; ++k) {
|
||||
final_score[k] =
|
||||
-3.402823466e+38f;
|
||||
|
||||
final_index[k] =
|
||||
0xffffffff;
|
||||
}
|
||||
|
||||
/*
|
||||
* Fonde gli 8 TOPK locali.
|
||||
*
|
||||
* Al massimo 8 × 32 = 256 candidati.
|
||||
*/
|
||||
for (uint l = 0;
|
||||
l < LANES_PER_QUERY;
|
||||
++l) {
|
||||
|
||||
for (uint k = 0;
|
||||
k < TOPK;
|
||||
++k) {
|
||||
|
||||
float s =
|
||||
local_scores[l][k];
|
||||
|
||||
uint idx =
|
||||
local_indices[l][k];
|
||||
|
||||
if (idx == 0xffffffff)
|
||||
continue;
|
||||
|
||||
if (s <= final_score[TOPK - 1])
|
||||
continue;
|
||||
|
||||
int pos = TOPK - 1;
|
||||
|
||||
while (pos > 0 &&
|
||||
s > final_score[pos - 1]) {
|
||||
--pos;
|
||||
}
|
||||
|
||||
for (int p = TOPK - 1;
|
||||
p > pos;
|
||||
--p) {
|
||||
|
||||
final_score[p] =
|
||||
final_score[p - 1];
|
||||
|
||||
final_index[p] =
|
||||
final_index[p - 1];
|
||||
}
|
||||
|
||||
final_score[pos] = s;
|
||||
final_index[pos] = idx;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Risultato finale della query.
|
||||
*/
|
||||
for (uint k = 0; k < TOPK; ++k) {
|
||||
indices[q * TOPK + k] =
|
||||
final_index[k];
|
||||
|
||||
values[q * TOPK + k] =
|
||||
final_score[k];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
856
docs/face-knn-source/src/face_engine.cc
Normal file
856
docs/face-knn-source/src/face_engine.cc
Normal file
|
|
@ -0,0 +1,856 @@
|
|||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#define STB_IMAGE_IMPLEMENTATION
|
||||
#include "stb_image.h"
|
||||
|
||||
#include "rknn_api.h"
|
||||
#include "face_engine.h"
|
||||
|
||||
static constexpr int SCRFD_SIZE = 640;
|
||||
static constexpr int SFACE_SIZE = 112;
|
||||
static constexpr int EMBED_DIM = 128;
|
||||
|
||||
static uint16_t float_to_fp16(float value)
|
||||
{
|
||||
uint32_t bits;
|
||||
std::memcpy(&bits, &value, sizeof(bits));
|
||||
|
||||
uint32_t sign = (bits >> 31) & 1;
|
||||
int exp = ((bits >> 23) & 0xff) - 127;
|
||||
uint32_t mant = bits & 0x7fffff;
|
||||
|
||||
if (exp == 128) {
|
||||
return (sign << 15) | 0x7c00;
|
||||
}
|
||||
|
||||
if (exp > 15) {
|
||||
return (sign << 15) | 0x7c00;
|
||||
}
|
||||
|
||||
if (exp < -14) {
|
||||
if (exp < -24)
|
||||
return sign << 15;
|
||||
|
||||
mant |= 0x800000;
|
||||
int shift = -exp - 14;
|
||||
uint16_t m = mant >> (shift + 13);
|
||||
|
||||
return (sign << 15) | m;
|
||||
}
|
||||
|
||||
uint16_t h_exp = (uint16_t)(exp + 15);
|
||||
uint16_t h_mant = (uint16_t)(mant >> 13);
|
||||
|
||||
return (sign << 15) | (h_exp << 10) | h_mant;
|
||||
}
|
||||
|
||||
static float fp16_to_float(uint16_t h)
|
||||
{
|
||||
uint32_t sign = (h >> 15) & 1;
|
||||
uint32_t exp = (h >> 10) & 0x1f;
|
||||
uint32_t mant = h & 0x3ff;
|
||||
|
||||
uint32_t bits;
|
||||
|
||||
if (exp == 0) {
|
||||
if (mant == 0) {
|
||||
bits = sign << 31;
|
||||
} else {
|
||||
float v = mant / 1024.0f;
|
||||
v = std::ldexp(v, -14);
|
||||
return sign ? -v : v;
|
||||
}
|
||||
} else if (exp == 31) {
|
||||
bits = (sign << 31) | 0x7f800000 | (mant << 13);
|
||||
} else {
|
||||
uint32_t fexp = exp - 15 + 127;
|
||||
bits = (sign << 31) | (fexp << 23) | (mant << 13);
|
||||
}
|
||||
|
||||
float v;
|
||||
std::memcpy(&v, &bits, sizeof(v));
|
||||
return v;
|
||||
}
|
||||
|
||||
static float iou(const Face& a, const Face& b)
|
||||
{
|
||||
float xx1 = std::max(a.x1, b.x1);
|
||||
float yy1 = std::max(a.y1, b.y1);
|
||||
float xx2 = std::min(a.x2, b.x2);
|
||||
float yy2 = std::min(a.y2, b.y2);
|
||||
|
||||
float w = std::max(0.0f, xx2 - xx1);
|
||||
float h = std::max(0.0f, yy2 - yy1);
|
||||
float inter = w * h;
|
||||
|
||||
float area_a = std::max(0.0f, a.x2-a.x1) *
|
||||
std::max(0.0f, a.y2-a.y1);
|
||||
|
||||
float area_b = std::max(0.0f, b.x2-b.x1) *
|
||||
std::max(0.0f, b.y2-b.y1);
|
||||
|
||||
return inter / (area_a + area_b - inter + 1e-6f);
|
||||
}
|
||||
|
||||
static void nms(std::vector<Face>& faces, float threshold)
|
||||
{
|
||||
std::sort(faces.begin(), faces.end(),
|
||||
[](const Face& a, const Face& b) {
|
||||
return a.score > b.score;
|
||||
});
|
||||
|
||||
std::vector<Face> result;
|
||||
|
||||
for (const auto& f : faces) {
|
||||
bool keep = true;
|
||||
|
||||
for (const auto& r : result) {
|
||||
if (iou(f, r) > threshold) {
|
||||
keep = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (keep)
|
||||
result.push_back(f);
|
||||
}
|
||||
|
||||
faces.swap(result);
|
||||
}
|
||||
|
||||
struct Image {
|
||||
int w = 0;
|
||||
int h = 0;
|
||||
std::vector<uint8_t> rgb;
|
||||
};
|
||||
|
||||
static bool normalize_rotation(
|
||||
int rotation_degrees,
|
||||
int& normalized_rotation)
|
||||
{
|
||||
int value = rotation_degrees % 360;
|
||||
|
||||
if (value < 0)
|
||||
value += 360;
|
||||
|
||||
if (
|
||||
value != 0
|
||||
&& value != 90
|
||||
&& value != 180
|
||||
&& value != 270
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
normalized_rotation = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
static Image rotate_image(
|
||||
const Image& source,
|
||||
int rotation_degrees)
|
||||
{
|
||||
if (rotation_degrees == 0)
|
||||
return source;
|
||||
|
||||
Image destination;
|
||||
|
||||
if (
|
||||
rotation_degrees == 90
|
||||
|| rotation_degrees == 270
|
||||
) {
|
||||
destination.w = source.h;
|
||||
destination.h = source.w;
|
||||
} else {
|
||||
destination.w = source.w;
|
||||
destination.h = source.h;
|
||||
}
|
||||
|
||||
destination.rgb.resize(
|
||||
static_cast<std::size_t>(destination.w)
|
||||
* static_cast<std::size_t>(destination.h)
|
||||
* 3);
|
||||
|
||||
for (int source_y = 0; source_y < source.h; ++source_y) {
|
||||
for (int source_x = 0; source_x < source.w; ++source_x) {
|
||||
int destination_x = 0;
|
||||
int destination_y = 0;
|
||||
|
||||
switch (rotation_degrees) {
|
||||
case 90:
|
||||
destination_x = source.h - 1 - source_y;
|
||||
destination_y = source_x;
|
||||
break;
|
||||
|
||||
case 180:
|
||||
destination_x = source.w - 1 - source_x;
|
||||
destination_y = source.h - 1 - source_y;
|
||||
break;
|
||||
|
||||
case 270:
|
||||
destination_x = source_y;
|
||||
destination_y = source.w - 1 - source_x;
|
||||
break;
|
||||
|
||||
default:
|
||||
destination_x = source_x;
|
||||
destination_y = source_y;
|
||||
break;
|
||||
}
|
||||
|
||||
const std::size_t source_offset =
|
||||
(
|
||||
static_cast<std::size_t>(source_y)
|
||||
* static_cast<std::size_t>(source.w)
|
||||
+ static_cast<std::size_t>(source_x)
|
||||
) * 3;
|
||||
|
||||
const std::size_t destination_offset =
|
||||
(
|
||||
static_cast<std::size_t>(destination_y)
|
||||
* static_cast<std::size_t>(destination.w)
|
||||
+ static_cast<std::size_t>(destination_x)
|
||||
) * 3;
|
||||
|
||||
destination.rgb[destination_offset + 0] =
|
||||
source.rgb[source_offset + 0];
|
||||
|
||||
destination.rgb[destination_offset + 1] =
|
||||
source.rgb[source_offset + 1];
|
||||
|
||||
destination.rgb[destination_offset + 2] =
|
||||
source.rgb[source_offset + 2];
|
||||
}
|
||||
}
|
||||
|
||||
return destination;
|
||||
}
|
||||
|
||||
static bool load_image(const std::string& path, Image& img)
|
||||
{
|
||||
int c = 0;
|
||||
|
||||
unsigned char* p =
|
||||
stbi_load(path.c_str(), &img.w, &img.h, &c, 3);
|
||||
|
||||
if (!p) {
|
||||
std::cerr << "Errore caricamento: " << path << "\n";
|
||||
return false;
|
||||
}
|
||||
|
||||
img.rgb.assign(p, p + img.w * img.h * 3);
|
||||
stbi_image_free(p);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static void resize_bilinear_rgb(
|
||||
const Image& src,
|
||||
std::vector<uint8_t>& dst,
|
||||
int dw,
|
||||
int dh)
|
||||
{
|
||||
dst.resize(dw * dh * 3);
|
||||
|
||||
float sx = (float)src.w / dw;
|
||||
float sy = (float)src.h / dh;
|
||||
|
||||
for (int y = 0; y < dh; ++y) {
|
||||
float fy = (y + 0.5f) * sy - 0.5f;
|
||||
int y0 = (int)std::floor(fy);
|
||||
float wy = fy - y0;
|
||||
|
||||
if (y0 < 0) {
|
||||
y0 = 0;
|
||||
wy = 0;
|
||||
}
|
||||
|
||||
int y1 = std::min(y0 + 1, src.h - 1);
|
||||
|
||||
for (int x = 0; x < dw; ++x) {
|
||||
float fx = (x + 0.5f) * sx - 0.5f;
|
||||
int x0 = (int)std::floor(fx);
|
||||
float wx = fx - x0;
|
||||
|
||||
if (x0 < 0) {
|
||||
x0 = 0;
|
||||
wx = 0;
|
||||
}
|
||||
|
||||
int x1 = std::min(x0 + 1, src.w - 1);
|
||||
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
float p00 = src.rgb[(y0*src.w+x0)*3+c];
|
||||
float p01 = src.rgb[(y0*src.w+x1)*3+c];
|
||||
float p10 = src.rgb[(y1*src.w+x0)*3+c];
|
||||
float p11 = src.rgb[(y1*src.w+x1)*3+c];
|
||||
|
||||
float v =
|
||||
p00 * (1-wx) * (1-wy) +
|
||||
p01 * wx * (1-wy) +
|
||||
p10 * (1-wx) * wy +
|
||||
p11 * wx * wy;
|
||||
|
||||
dst[(y*dw+x)*3+c] =
|
||||
(uint8_t)std::clamp((int)std::round(v), 0, 255);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Scrfd {
|
||||
public:
|
||||
rknn_context ctx = 0;
|
||||
|
||||
bool init(const char* model)
|
||||
{
|
||||
FILE* fp = fopen(model, "rb");
|
||||
if (!fp) {
|
||||
perror(model);
|
||||
return false;
|
||||
}
|
||||
|
||||
fseek(fp, 0, SEEK_END);
|
||||
size_t size = ftell(fp);
|
||||
fseek(fp, 0, SEEK_SET);
|
||||
|
||||
std::vector<uint8_t> data(size);
|
||||
fread(data.data(), 1, size, fp);
|
||||
fclose(fp);
|
||||
|
||||
int ret = rknn_init(&ctx, data.data(), size, 0, nullptr);
|
||||
if (ret != 0) {
|
||||
std::cerr << "rknn_init SCRFD failed: " << ret << "\n";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
std::vector<Face> detect(const Image& image)
|
||||
{
|
||||
float scale =
|
||||
std::min(640.0f / image.w, 640.0f / image.h);
|
||||
|
||||
int rw = (int)std::round(image.w * scale);
|
||||
int rh = (int)std::round(image.h * scale);
|
||||
|
||||
std::vector<uint8_t> resized;
|
||||
resize_bilinear_rgb(image, resized, rw, rh);
|
||||
|
||||
std::vector<uint16_t> input(
|
||||
SCRFD_SIZE * SCRFD_SIZE * 3);
|
||||
|
||||
for (int y = 0; y < rh; ++y) {
|
||||
for (int x = 0; x < rw; ++x) {
|
||||
int dst = (y * SCRFD_SIZE + x) * 3;
|
||||
int src = (y * rw + x) * 3;
|
||||
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
float v =
|
||||
((float)resized[src+c] - 127.5f) / 128.0f;
|
||||
|
||||
input[dst+c] = float_to_fp16(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rknn_input in{};
|
||||
in.index = 0;
|
||||
in.type = RKNN_TENSOR_FLOAT16;
|
||||
in.fmt = RKNN_TENSOR_NHWC;
|
||||
in.size = input.size() * sizeof(uint16_t);
|
||||
in.buf = input.data();
|
||||
in.pass_through = 1;
|
||||
|
||||
int ret = rknn_inputs_set(ctx, 1, &in);
|
||||
if (ret != 0) {
|
||||
std::cerr << "SCRFD inputs_set failed\n";
|
||||
return {};
|
||||
}
|
||||
|
||||
ret = rknn_run(ctx, nullptr);
|
||||
if (ret != 0) {
|
||||
std::cerr << "SCRFD run failed\n";
|
||||
return {};
|
||||
}
|
||||
|
||||
rknn_output outputs[9]{};
|
||||
|
||||
for (int i = 0; i < 9; ++i) {
|
||||
outputs[i].want_float = 0;
|
||||
}
|
||||
|
||||
ret = rknn_outputs_get(ctx, 9, outputs, nullptr);
|
||||
if (ret != 0) {
|
||||
std::cerr << "SCRFD outputs_get failed\n";
|
||||
return {};
|
||||
}
|
||||
|
||||
const int strides[3] = {8, 16, 32};
|
||||
const int counts[3] = {12800, 3200, 800};
|
||||
|
||||
std::vector<Face> faces;
|
||||
|
||||
const float score_threshold = 0.50f;
|
||||
|
||||
for (int level = 0; level < 3; ++level) {
|
||||
const int stride = strides[level];
|
||||
const int count = counts[level];
|
||||
|
||||
const uint16_t* scores =
|
||||
(const uint16_t*)outputs[level].buf;
|
||||
|
||||
const uint16_t* bbox =
|
||||
(const uint16_t*)outputs[3 + level].buf;
|
||||
|
||||
const uint16_t* kps =
|
||||
(const uint16_t*)outputs[6 + level].buf;
|
||||
|
||||
int feat_w = SCRFD_SIZE / stride;
|
||||
|
||||
for (int i = 0; i < count; ++i) {
|
||||
float score = fp16_to_float(scores[i]);
|
||||
|
||||
if (score < score_threshold)
|
||||
continue;
|
||||
|
||||
int anchor = i % 2;
|
||||
int p = i / 2;
|
||||
|
||||
int gx = p % feat_w;
|
||||
int gy = p / feat_w;
|
||||
|
||||
float cx = gx * stride;
|
||||
float cy = gy * stride;
|
||||
|
||||
/*
|
||||
* AnchorGenerator con due anchor:
|
||||
* centers duplicati sullo stesso punto.
|
||||
*/
|
||||
|
||||
(void)anchor;
|
||||
|
||||
float l = fp16_to_float(bbox[i*4+0]) * stride;
|
||||
float t = fp16_to_float(bbox[i*4+1]) * stride;
|
||||
float r = fp16_to_float(bbox[i*4+2]) * stride;
|
||||
float b = fp16_to_float(bbox[i*4+3]) * stride;
|
||||
|
||||
Face f;
|
||||
f.score = score;
|
||||
|
||||
f.x1 = (cx - l) / scale;
|
||||
f.y1 = (cy - t) / scale;
|
||||
f.x2 = (cx + r) / scale;
|
||||
f.y2 = (cy + b) / scale;
|
||||
|
||||
for (int j = 0; j < 5; ++j) {
|
||||
float x =
|
||||
cx + fp16_to_float(kps[i*10+j*2])
|
||||
* stride;
|
||||
|
||||
float y =
|
||||
cy + fp16_to_float(kps[i*10+j*2+1])
|
||||
* stride;
|
||||
|
||||
f.kps[j].x = x / scale;
|
||||
f.kps[j].y = y / scale;
|
||||
}
|
||||
|
||||
f.x1 = std::clamp(f.x1, 0.0f, (float)image.w);
|
||||
f.y1 = std::clamp(f.y1, 0.0f, (float)image.h);
|
||||
f.x2 = std::clamp(f.x2, 0.0f, (float)image.w);
|
||||
f.y2 = std::clamp(f.y2, 0.0f, (float)image.h);
|
||||
|
||||
faces.push_back(f);
|
||||
}
|
||||
}
|
||||
|
||||
rknn_outputs_release(ctx, 9, outputs);
|
||||
|
||||
nms(faces, 0.45f);
|
||||
|
||||
return faces;
|
||||
}
|
||||
|
||||
~Scrfd()
|
||||
{
|
||||
if (ctx)
|
||||
rknn_destroy(ctx);
|
||||
}
|
||||
};
|
||||
|
||||
static const Point sface_template[5] = {
|
||||
{38.2946f, 51.6963f},
|
||||
{73.5318f, 51.5014f},
|
||||
{56.0252f, 71.7366f},
|
||||
{41.5493f, 92.3655f},
|
||||
{70.7299f, 92.2041f}
|
||||
};
|
||||
|
||||
/*
|
||||
* Trova la trasformazione similarity:
|
||||
*
|
||||
* x' = a*x - b*y + tx
|
||||
* y' = b*x + a*y + ty
|
||||
*/
|
||||
static void similarity_transform(
|
||||
const Point src[5],
|
||||
float& a,
|
||||
float& b,
|
||||
float& tx,
|
||||
float& ty)
|
||||
{
|
||||
double sx = 0, sy = 0;
|
||||
double dx = 0, dy = 0;
|
||||
|
||||
for (int i = 0; i < 5; ++i) {
|
||||
sx += src[i].x;
|
||||
sy += src[i].y;
|
||||
dx += sface_template[i].x;
|
||||
dy += sface_template[i].y;
|
||||
}
|
||||
|
||||
sx /= 5;
|
||||
sy /= 5;
|
||||
dx /= 5;
|
||||
dy /= 5;
|
||||
|
||||
double num_a = 0;
|
||||
double num_b = 0;
|
||||
double den = 0;
|
||||
|
||||
for (int i = 0; i < 5; ++i) {
|
||||
double x = src[i].x - sx;
|
||||
double y = src[i].y - sy;
|
||||
|
||||
double X = sface_template[i].x - dx;
|
||||
double Y = sface_template[i].y - dy;
|
||||
|
||||
num_a += x*X + y*Y;
|
||||
num_b += x*Y - y*X;
|
||||
den += x*x + y*y;
|
||||
}
|
||||
|
||||
a = (float)(num_a / den);
|
||||
b = (float)(num_b / den);
|
||||
|
||||
tx = (float)(dx - a*sx + b*sy);
|
||||
ty = (float)(dy - b*sx - a*sy);
|
||||
}
|
||||
|
||||
static std::vector<uint16_t> align_face(
|
||||
const Image& image,
|
||||
const Face& face)
|
||||
{
|
||||
float a, b, tx, ty;
|
||||
|
||||
similarity_transform(face.kps, a, b, tx, ty);
|
||||
|
||||
/*
|
||||
* Inversa della similarity transform.
|
||||
*
|
||||
* src = A^-1 * (dst - t)
|
||||
*/
|
||||
float denom = a*a + b*b;
|
||||
|
||||
std::vector<uint16_t> output(
|
||||
SFACE_SIZE * SFACE_SIZE * 3);
|
||||
|
||||
for (int y = 0; y < SFACE_SIZE; ++y) {
|
||||
for (int x = 0; x < SFACE_SIZE; ++x) {
|
||||
|
||||
float X = x - tx;
|
||||
float Y = y - ty;
|
||||
|
||||
float sx =
|
||||
(a*X + b*Y) / denom;
|
||||
|
||||
float sy =
|
||||
(-b*X + a*Y) / denom;
|
||||
|
||||
int x0 = (int)std::floor(sx);
|
||||
int y0 = (int)std::floor(sy);
|
||||
|
||||
float wx = sx - x0;
|
||||
float wy = sy - y0;
|
||||
|
||||
uint8_t rgb[3] = {0,0,0};
|
||||
|
||||
if (x0 >= 0 &&
|
||||
y0 >= 0 &&
|
||||
x0 + 1 < image.w &&
|
||||
y0 + 1 < image.h) {
|
||||
|
||||
int x1 = x0 + 1;
|
||||
int y1 = y0 + 1;
|
||||
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
float p00 =
|
||||
image.rgb[(y0*image.w+x0)*3+c];
|
||||
|
||||
float p01 =
|
||||
image.rgb[(y0*image.w+x1)*3+c];
|
||||
|
||||
float p10 =
|
||||
image.rgb[(y1*image.w+x0)*3+c];
|
||||
|
||||
float p11 =
|
||||
image.rgb[(y1*image.w+x1)*3+c];
|
||||
|
||||
float v =
|
||||
p00*(1-wx)*(1-wy) +
|
||||
p01*wx*(1-wy) +
|
||||
p10*(1-wx)*wy +
|
||||
p11*wx*wy;
|
||||
|
||||
rgb[c] =
|
||||
(uint8_t)std::clamp(
|
||||
(int)std::round(v), 0, 255);
|
||||
}
|
||||
}
|
||||
|
||||
int idx = (y*SFACE_SIZE+x)*3;
|
||||
|
||||
/*
|
||||
* SFace OpenCV:
|
||||
* blobFromImage(..., swapRB=true, scalefactor=1)
|
||||
*
|
||||
* Noi partiamo già da RGB, quindi non facciamo
|
||||
* nessun ulteriore swap e nessuna normalizzazione.
|
||||
*/
|
||||
output[idx+0] = float_to_fp16(rgb[0]);
|
||||
output[idx+1] = float_to_fp16(rgb[1]);
|
||||
output[idx+2] = float_to_fp16(rgb[2]);
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
class SFace {
|
||||
public:
|
||||
rknn_context ctx = 0;
|
||||
|
||||
bool init(const char* model)
|
||||
{
|
||||
FILE* fp = fopen(model, "rb");
|
||||
if (!fp) {
|
||||
perror(model);
|
||||
return false;
|
||||
}
|
||||
|
||||
fseek(fp, 0, SEEK_END);
|
||||
size_t size = ftell(fp);
|
||||
fseek(fp, 0, SEEK_SET);
|
||||
|
||||
std::vector<uint8_t> data(size);
|
||||
fread(data.data(), 1, size, fp);
|
||||
fclose(fp);
|
||||
|
||||
int ret = rknn_init(&ctx, data.data(), size, 0, nullptr);
|
||||
|
||||
if (ret != 0) {
|
||||
std::cerr << "rknn_init SFace failed: "
|
||||
<< ret << "\n";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
std::vector<float> feature(
|
||||
const std::vector<uint16_t>& input)
|
||||
{
|
||||
rknn_input in{};
|
||||
|
||||
in.index = 0;
|
||||
in.type = RKNN_TENSOR_FLOAT16;
|
||||
in.fmt = RKNN_TENSOR_NHWC;
|
||||
in.size = input.size() * sizeof(uint16_t);
|
||||
in.buf = (void*)input.data();
|
||||
in.pass_through = 1;
|
||||
|
||||
int ret = rknn_inputs_set(ctx, 1, &in);
|
||||
|
||||
if (ret != 0) {
|
||||
std::cerr << "SFace inputs_set failed\n";
|
||||
return {};
|
||||
}
|
||||
|
||||
ret = rknn_run(ctx, nullptr);
|
||||
|
||||
if (ret != 0) {
|
||||
std::cerr << "SFace run failed\n";
|
||||
return {};
|
||||
}
|
||||
|
||||
rknn_output output{};
|
||||
|
||||
output.want_float = 0;
|
||||
|
||||
ret = rknn_outputs_get(
|
||||
ctx, 1, &output, nullptr);
|
||||
|
||||
if (ret != 0) {
|
||||
std::cerr << "SFace outputs_get failed\n";
|
||||
return {};
|
||||
}
|
||||
|
||||
const uint16_t* p =
|
||||
(const uint16_t*)output.buf;
|
||||
|
||||
std::vector<float> emb(EMBED_DIM);
|
||||
|
||||
for (int i = 0; i < EMBED_DIM; ++i)
|
||||
emb[i] = fp16_to_float(p[i]);
|
||||
|
||||
rknn_outputs_release(ctx, 1, &output);
|
||||
|
||||
/*
|
||||
* L2 normalization.
|
||||
*/
|
||||
double norm = 0;
|
||||
|
||||
for (float v : emb)
|
||||
norm += (double)v * v;
|
||||
|
||||
norm = std::sqrt(norm);
|
||||
|
||||
if (norm > 0) {
|
||||
for (float& v : emb)
|
||||
v /= (float)norm;
|
||||
}
|
||||
|
||||
return emb;
|
||||
}
|
||||
|
||||
~SFace()
|
||||
{
|
||||
if (ctx)
|
||||
rknn_destroy(ctx);
|
||||
}
|
||||
};
|
||||
|
||||
static std::vector<Face> process_image(
|
||||
const Image& image,
|
||||
Scrfd& detector,
|
||||
SFace& recognizer)
|
||||
{
|
||||
auto faces = detector.detect(image);
|
||||
|
||||
for (auto& face : faces) {
|
||||
auto aligned = align_face(image, face);
|
||||
face.embedding =
|
||||
recognizer.feature(aligned);
|
||||
}
|
||||
|
||||
return faces;
|
||||
}
|
||||
|
||||
struct FaceEngine::Impl {
|
||||
Scrfd detector;
|
||||
SFace recognizer;
|
||||
bool initialized = false;
|
||||
};
|
||||
|
||||
FaceEngine::FaceEngine()
|
||||
: impl_(new Impl())
|
||||
{
|
||||
}
|
||||
|
||||
FaceEngine::~FaceEngine()
|
||||
{
|
||||
delete impl_;
|
||||
}
|
||||
|
||||
bool FaceEngine::init(
|
||||
const std::string& scrfd_model_path,
|
||||
const std::string& sface_model_path)
|
||||
{
|
||||
if (impl_->initialized)
|
||||
return true;
|
||||
|
||||
if (!impl_->detector.init(scrfd_model_path.c_str()))
|
||||
return false;
|
||||
|
||||
if (!impl_->recognizer.init(sface_model_path.c_str()))
|
||||
return false;
|
||||
|
||||
impl_->initialized = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool FaceEngine::process_image(
|
||||
const std::string& image_path,
|
||||
std::vector<Face>& faces,
|
||||
int& image_width,
|
||||
int& image_height,
|
||||
int rotation_degrees)
|
||||
{
|
||||
faces.clear();
|
||||
image_width = 0;
|
||||
image_height = 0;
|
||||
|
||||
if (!impl_->initialized) {
|
||||
std::cerr << "FaceEngine non inizializzato\n";
|
||||
return false;
|
||||
}
|
||||
|
||||
int normalized_rotation = 0;
|
||||
|
||||
if (!normalize_rotation(
|
||||
rotation_degrees,
|
||||
normalized_rotation)) {
|
||||
std::cerr
|
||||
<< "Rotazione non valida: "
|
||||
<< rotation_degrees
|
||||
<< ". Valori ammessi: 0, 90, 180, 270\n";
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
Image source_image;
|
||||
|
||||
if (!load_image(image_path, source_image))
|
||||
return false;
|
||||
|
||||
Image oriented_image = rotate_image(
|
||||
source_image,
|
||||
normalized_rotation);
|
||||
|
||||
image_width = oriented_image.w;
|
||||
image_height = oriented_image.h;
|
||||
|
||||
faces = ::process_image(
|
||||
oriented_image,
|
||||
impl_->detector,
|
||||
impl_->recognizer);
|
||||
|
||||
for (const Face& face : faces) {
|
||||
if (face.embedding.size() != EMBED_DIM) {
|
||||
std::cerr
|
||||
<< "Embedding non valido per "
|
||||
<< image_path
|
||||
<< ": attesi "
|
||||
<< EMBED_DIM
|
||||
<< " valori, trovati "
|
||||
<< face.embedding.size()
|
||||
<< "\n";
|
||||
|
||||
faces.clear();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
781
docs/face-knn-source/src/face_knn.cc
Normal file
781
docs/face-knn-source/src/face_knn.cc
Normal file
|
|
@ -0,0 +1,781 @@
|
|||
#include "knn_opencl.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <fstream>
|
||||
#include <iomanip>
|
||||
#include <iostream>
|
||||
#include <limits>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <cstdio>
|
||||
#include <unistd.h>
|
||||
|
||||
namespace {
|
||||
|
||||
|
||||
class ScopedStdoutToStderr {
|
||||
public:
|
||||
ScopedStdoutToStderr()
|
||||
{
|
||||
std::cout.flush();
|
||||
std::fflush(stdout);
|
||||
|
||||
saved_stdout_ =
|
||||
::dup(STDOUT_FILENO);
|
||||
|
||||
if (saved_stdout_ < 0) {
|
||||
throw std::runtime_error(
|
||||
"Impossibile salvare stdout"
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
::dup2(
|
||||
STDERR_FILENO,
|
||||
STDOUT_FILENO
|
||||
) < 0
|
||||
) {
|
||||
::close(saved_stdout_);
|
||||
saved_stdout_ = -1;
|
||||
|
||||
throw std::runtime_error(
|
||||
"Impossibile deviare stdout"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
~ScopedStdoutToStderr()
|
||||
{
|
||||
if (saved_stdout_ < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::cout.flush();
|
||||
std::fflush(stdout);
|
||||
|
||||
::dup2(
|
||||
saved_stdout_,
|
||||
STDOUT_FILENO
|
||||
);
|
||||
|
||||
::close(saved_stdout_);
|
||||
}
|
||||
|
||||
ScopedStdoutToStderr(
|
||||
const ScopedStdoutToStderr&
|
||||
) = delete;
|
||||
|
||||
ScopedStdoutToStderr& operator=(
|
||||
const ScopedStdoutToStderr&
|
||||
) = delete;
|
||||
|
||||
private:
|
||||
int saved_stdout_ = -1;
|
||||
};
|
||||
|
||||
|
||||
struct Arguments {
|
||||
std::string queries_path;
|
||||
|
||||
std::string database_path;
|
||||
|
||||
std::string self_map_path;
|
||||
|
||||
std::string backend = "cpu";
|
||||
|
||||
std::size_t query_count = 0;
|
||||
|
||||
std::size_t database_count = 0;
|
||||
|
||||
std::uint32_t dimension = 128;
|
||||
|
||||
std::uint32_t top_k = 1;
|
||||
|
||||
float threshold = 0.50f;
|
||||
};
|
||||
|
||||
|
||||
void print_usage(
|
||||
const char* program)
|
||||
{
|
||||
std::cerr
|
||||
<< "Uso:\n"
|
||||
<< " "
|
||||
<< program
|
||||
<< " --queries FILE"
|
||||
<< " --database FILE"
|
||||
<< " --query-count N"
|
||||
<< " --database-count N"
|
||||
<< " --dimension N"
|
||||
<< " --top-k N"
|
||||
<< " --backend cpu"
|
||||
<< " [--threshold SCORE]"
|
||||
<< " [--self-map FILE]"
|
||||
<< "\n";
|
||||
}
|
||||
|
||||
|
||||
std::string require_value(
|
||||
int argc,
|
||||
char** argv,
|
||||
int& index)
|
||||
{
|
||||
if (index + 1 >= argc) {
|
||||
throw std::invalid_argument(
|
||||
std::string(
|
||||
"Valore mancante per "
|
||||
)
|
||||
+ argv[index]
|
||||
);
|
||||
}
|
||||
|
||||
index += 1;
|
||||
|
||||
return argv[index];
|
||||
}
|
||||
|
||||
|
||||
std::size_t parse_size(
|
||||
const std::string& text,
|
||||
const char* name)
|
||||
{
|
||||
std::size_t consumed = 0;
|
||||
|
||||
const auto value =
|
||||
std::stoull(
|
||||
text,
|
||||
&consumed,
|
||||
10
|
||||
);
|
||||
|
||||
if (
|
||||
consumed != text.size()
|
||||
|| value == 0
|
||||
) {
|
||||
throw std::invalid_argument(
|
||||
std::string(name)
|
||||
+ " non valido: "
|
||||
+ text
|
||||
);
|
||||
}
|
||||
|
||||
return static_cast<std::size_t>(
|
||||
value
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
std::uint32_t parse_u32(
|
||||
const std::string& text,
|
||||
const char* name)
|
||||
{
|
||||
const std::size_t value =
|
||||
parse_size(
|
||||
text,
|
||||
name
|
||||
);
|
||||
|
||||
if (
|
||||
value
|
||||
> std::numeric_limits<
|
||||
std::uint32_t
|
||||
>::max()
|
||||
) {
|
||||
throw std::invalid_argument(
|
||||
std::string(name)
|
||||
+ " supera uint32"
|
||||
);
|
||||
}
|
||||
|
||||
return static_cast<std::uint32_t>(
|
||||
value
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
float parse_float(
|
||||
const std::string& text,
|
||||
const char* name)
|
||||
{
|
||||
std::size_t consumed = 0;
|
||||
|
||||
const float value =
|
||||
std::stof(
|
||||
text,
|
||||
&consumed
|
||||
);
|
||||
|
||||
if (consumed != text.size()) {
|
||||
throw std::invalid_argument(
|
||||
std::string(name)
|
||||
+ " non valido: "
|
||||
+ text
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
Arguments parse_arguments(
|
||||
int argc,
|
||||
char** argv)
|
||||
{
|
||||
Arguments arguments;
|
||||
|
||||
for (
|
||||
int index = 1;
|
||||
index < argc;
|
||||
++index
|
||||
) {
|
||||
const std::string option =
|
||||
argv[index];
|
||||
|
||||
if (
|
||||
option == "--help"
|
||||
|| option == "-h"
|
||||
) {
|
||||
print_usage(argv[0]);
|
||||
std::exit(0);
|
||||
}
|
||||
|
||||
if (option == "--queries") {
|
||||
arguments.queries_path =
|
||||
require_value(
|
||||
argc,
|
||||
argv,
|
||||
index
|
||||
);
|
||||
} else if (
|
||||
option == "--database"
|
||||
) {
|
||||
arguments.database_path =
|
||||
require_value(
|
||||
argc,
|
||||
argv,
|
||||
index
|
||||
);
|
||||
} else if (
|
||||
option == "--self-map"
|
||||
) {
|
||||
arguments.self_map_path =
|
||||
require_value(
|
||||
argc,
|
||||
argv,
|
||||
index
|
||||
);
|
||||
} else if (
|
||||
option == "--backend"
|
||||
) {
|
||||
arguments.backend =
|
||||
require_value(
|
||||
argc,
|
||||
argv,
|
||||
index
|
||||
);
|
||||
} else if (
|
||||
option == "--query-count"
|
||||
) {
|
||||
arguments.query_count =
|
||||
parse_size(
|
||||
require_value(
|
||||
argc,
|
||||
argv,
|
||||
index
|
||||
),
|
||||
"query-count"
|
||||
);
|
||||
} else if (
|
||||
option == "--database-count"
|
||||
) {
|
||||
arguments.database_count =
|
||||
parse_size(
|
||||
require_value(
|
||||
argc,
|
||||
argv,
|
||||
index
|
||||
),
|
||||
"database-count"
|
||||
);
|
||||
} else if (
|
||||
option == "--dimension"
|
||||
) {
|
||||
arguments.dimension =
|
||||
parse_u32(
|
||||
require_value(
|
||||
argc,
|
||||
argv,
|
||||
index
|
||||
),
|
||||
"dimension"
|
||||
);
|
||||
} else if (
|
||||
option == "--top-k"
|
||||
) {
|
||||
arguments.top_k =
|
||||
parse_u32(
|
||||
require_value(
|
||||
argc,
|
||||
argv,
|
||||
index
|
||||
),
|
||||
"top-k"
|
||||
);
|
||||
} else if (
|
||||
option == "--threshold"
|
||||
) {
|
||||
arguments.threshold =
|
||||
parse_float(
|
||||
require_value(
|
||||
argc,
|
||||
argv,
|
||||
index
|
||||
),
|
||||
"threshold"
|
||||
);
|
||||
} else {
|
||||
throw std::invalid_argument(
|
||||
"Opzione sconosciuta: "
|
||||
+ option
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
arguments.queries_path.empty()
|
||||
|| arguments.database_path.empty()
|
||||
|| arguments.query_count == 0
|
||||
|| arguments.database_count == 0
|
||||
) {
|
||||
throw std::invalid_argument(
|
||||
"Argomenti obbligatori mancanti"
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
arguments.backend != "cpu"
|
||||
&& arguments.backend != "opencl"
|
||||
) {
|
||||
throw std::invalid_argument(
|
||||
"Backend non supportato: "
|
||||
+ arguments.backend
|
||||
);
|
||||
}
|
||||
|
||||
return arguments;
|
||||
}
|
||||
|
||||
|
||||
std::vector<float> read_float_file(
|
||||
const std::string& path,
|
||||
std::size_t expected_values)
|
||||
{
|
||||
std::ifstream input(
|
||||
path,
|
||||
std::ios::binary
|
||||
);
|
||||
|
||||
if (!input) {
|
||||
throw std::runtime_error(
|
||||
"Impossibile aprire: "
|
||||
+ path
|
||||
);
|
||||
}
|
||||
|
||||
input.seekg(
|
||||
0,
|
||||
std::ios::end
|
||||
);
|
||||
|
||||
const std::streamoff byte_count =
|
||||
input.tellg();
|
||||
|
||||
input.seekg(
|
||||
0,
|
||||
std::ios::beg
|
||||
);
|
||||
|
||||
const auto expected_bytes =
|
||||
static_cast<std::streamoff>(
|
||||
expected_values
|
||||
* sizeof(float)
|
||||
);
|
||||
|
||||
if (byte_count != expected_bytes) {
|
||||
throw std::runtime_error(
|
||||
"Dimensione file incoerente: "
|
||||
+ path
|
||||
+ ", byte="
|
||||
+ std::to_string(byte_count)
|
||||
+ ", attesi="
|
||||
+ std::to_string(
|
||||
expected_bytes
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
std::vector<float> values(
|
||||
expected_values
|
||||
);
|
||||
|
||||
input.read(
|
||||
reinterpret_cast<char*>(
|
||||
values.data()
|
||||
),
|
||||
byte_count
|
||||
);
|
||||
|
||||
if (!input) {
|
||||
throw std::runtime_error(
|
||||
"Lettura incompleta: "
|
||||
+ path
|
||||
);
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
|
||||
std::vector<std::int64_t> read_self_map(
|
||||
const std::string& path,
|
||||
std::size_t query_count)
|
||||
{
|
||||
std::vector<std::int64_t> values(
|
||||
query_count,
|
||||
-1
|
||||
);
|
||||
|
||||
if (path.empty()) {
|
||||
return values;
|
||||
}
|
||||
|
||||
std::ifstream input(path);
|
||||
|
||||
if (!input) {
|
||||
throw std::runtime_error(
|
||||
"Impossibile aprire self-map: "
|
||||
+ path
|
||||
);
|
||||
}
|
||||
|
||||
std::size_t index = 0;
|
||||
std::int64_t value = -1;
|
||||
|
||||
while (input >> value) {
|
||||
if (index >= query_count) {
|
||||
throw std::runtime_error(
|
||||
"Self-map contiene "
|
||||
"troppe righe"
|
||||
);
|
||||
}
|
||||
|
||||
values[index] = value;
|
||||
index += 1;
|
||||
}
|
||||
|
||||
if (index != query_count) {
|
||||
throw std::runtime_error(
|
||||
"Self-map contiene "
|
||||
+ std::to_string(index)
|
||||
+ " righe, attese "
|
||||
+ std::to_string(
|
||||
query_count
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
|
||||
void write_json(
|
||||
const face_knn::SearchOutput& output,
|
||||
const std::vector<std::int64_t>& self_map,
|
||||
float threshold)
|
||||
{
|
||||
std::size_t recognized_count = 0;
|
||||
|
||||
std::cout
|
||||
<< std::setprecision(9)
|
||||
<< "{\n"
|
||||
<< " \"backend\": \""
|
||||
<< output.backend
|
||||
<< "\",\n"
|
||||
<< " \"device_name\": \""
|
||||
<< output.device_name
|
||||
<< "\",\n"
|
||||
<< " \"query_count\": "
|
||||
<< output.query_count
|
||||
<< ",\n"
|
||||
<< " \"database_count\": "
|
||||
<< output.database_count
|
||||
<< ",\n"
|
||||
<< " \"dimension\": "
|
||||
<< output.dimension
|
||||
<< ",\n"
|
||||
<< " \"top_k\": "
|
||||
<< output.top_k
|
||||
<< ",\n"
|
||||
<< " \"threshold\": "
|
||||
<< threshold
|
||||
<< ",\n"
|
||||
<< " \"leave_one_out\": "
|
||||
<< (
|
||||
self_map.empty()
|
||||
? "false"
|
||||
: "true"
|
||||
)
|
||||
<< ",\n"
|
||||
<< " \"elapsed_ms\": "
|
||||
<< output.elapsed_ms
|
||||
<< ",\n"
|
||||
<< " \"results\": [\n";
|
||||
|
||||
for (
|
||||
std::size_t query_index = 0;
|
||||
query_index < output.query_count;
|
||||
++query_index
|
||||
) {
|
||||
auto neighbors =
|
||||
face_knn::query_neighbors(
|
||||
output,
|
||||
query_index
|
||||
);
|
||||
|
||||
const std::int64_t excluded =
|
||||
self_map[query_index];
|
||||
|
||||
if (excluded >= 0) {
|
||||
neighbors =
|
||||
face_knn::exclude_neighbor(
|
||||
neighbors,
|
||||
static_cast<
|
||||
std::uint32_t
|
||||
>(excluded)
|
||||
);
|
||||
}
|
||||
|
||||
if (neighbors.empty()) {
|
||||
throw std::runtime_error(
|
||||
"Nessun vicino disponibile "
|
||||
"dopo leave-one-out"
|
||||
);
|
||||
}
|
||||
|
||||
const auto& best =
|
||||
neighbors.front();
|
||||
|
||||
const bool recognized =
|
||||
best.score >= threshold;
|
||||
|
||||
if (recognized) {
|
||||
recognized_count += 1;
|
||||
}
|
||||
|
||||
std::cout
|
||||
<< " {\n"
|
||||
<< " \"query_index\": "
|
||||
<< query_index
|
||||
<< ",\n"
|
||||
<< " \"excluded_index\": ";
|
||||
|
||||
if (excluded < 0) {
|
||||
std::cout << "null";
|
||||
} else {
|
||||
std::cout << excluded;
|
||||
}
|
||||
|
||||
std::cout
|
||||
<< ",\n"
|
||||
<< " \"recognized\": "
|
||||
<< (
|
||||
recognized
|
||||
? "true"
|
||||
: "false"
|
||||
)
|
||||
<< ",\n"
|
||||
<< " \"best_index\": "
|
||||
<< best.index
|
||||
<< ",\n"
|
||||
<< " \"best_score\": "
|
||||
<< best.score
|
||||
<< ",\n"
|
||||
<< " \"neighbors\": [\n";
|
||||
|
||||
for (
|
||||
std::size_t position = 0;
|
||||
position < neighbors.size();
|
||||
++position
|
||||
) {
|
||||
const auto& neighbor =
|
||||
neighbors[position];
|
||||
|
||||
std::cout
|
||||
<< " {"
|
||||
<< "\"index\": "
|
||||
<< neighbor.index
|
||||
<< ", \"score\": "
|
||||
<< neighbor.score
|
||||
<< "}";
|
||||
|
||||
if (
|
||||
position + 1
|
||||
< neighbors.size()
|
||||
) {
|
||||
std::cout << ",";
|
||||
}
|
||||
|
||||
std::cout << "\n";
|
||||
}
|
||||
|
||||
std::cout
|
||||
<< " ]\n"
|
||||
<< " }";
|
||||
|
||||
if (
|
||||
query_index + 1
|
||||
< output.query_count
|
||||
) {
|
||||
std::cout << ",";
|
||||
}
|
||||
|
||||
std::cout << "\n";
|
||||
}
|
||||
|
||||
const std::size_t below_count =
|
||||
output.query_count
|
||||
- recognized_count;
|
||||
|
||||
const double coverage =
|
||||
output.query_count > 0
|
||||
? (
|
||||
static_cast<double>(
|
||||
recognized_count
|
||||
)
|
||||
/ static_cast<double>(
|
||||
output.query_count
|
||||
)
|
||||
* 100.0
|
||||
)
|
||||
: 0.0;
|
||||
|
||||
std::cout
|
||||
<< " ],\n"
|
||||
<< " \"recognized_count\": "
|
||||
<< recognized_count
|
||||
<< ",\n"
|
||||
<< " \"below_threshold_count\": "
|
||||
<< below_count
|
||||
<< ",\n"
|
||||
<< " \"coverage_percent\": "
|
||||
<< coverage
|
||||
<< "\n"
|
||||
<< "}\n";
|
||||
}
|
||||
|
||||
|
||||
} // namespace
|
||||
|
||||
|
||||
int main(
|
||||
int argc,
|
||||
char** argv)
|
||||
{
|
||||
try {
|
||||
const Arguments arguments =
|
||||
parse_arguments(
|
||||
argc,
|
||||
argv
|
||||
);
|
||||
|
||||
const std::size_t query_values =
|
||||
arguments.query_count
|
||||
* arguments.dimension;
|
||||
|
||||
const std::size_t database_values =
|
||||
arguments.database_count
|
||||
* arguments.dimension;
|
||||
|
||||
const auto queries =
|
||||
read_float_file(
|
||||
arguments.queries_path,
|
||||
query_values
|
||||
);
|
||||
|
||||
const auto database =
|
||||
read_float_file(
|
||||
arguments.database_path,
|
||||
database_values
|
||||
);
|
||||
|
||||
const auto self_map =
|
||||
read_self_map(
|
||||
arguments.self_map_path,
|
||||
arguments.query_count
|
||||
);
|
||||
|
||||
face_knn::SearchOptions options;
|
||||
|
||||
options.dimension =
|
||||
arguments.dimension;
|
||||
|
||||
options.top_k =
|
||||
arguments.top_k;
|
||||
|
||||
options.kernel_path =
|
||||
"kernels/knn_float4_topk.cl";
|
||||
|
||||
face_knn::SearchOutput output;
|
||||
|
||||
if (
|
||||
arguments.backend == "opencl"
|
||||
) {
|
||||
/*
|
||||
* Il runtime Mali scrive la propria versione
|
||||
* direttamente su stdout. Durante OpenCL
|
||||
* deviamo stdout su stderr, affinché l'output
|
||||
* JSON resti puro.
|
||||
*/
|
||||
ScopedStdoutToStderr redirect;
|
||||
|
||||
output =
|
||||
face_knn::search_opencl(
|
||||
database,
|
||||
queries,
|
||||
arguments.database_count,
|
||||
arguments.query_count,
|
||||
options
|
||||
);
|
||||
} else {
|
||||
output =
|
||||
face_knn::search_cpu(
|
||||
database,
|
||||
queries,
|
||||
arguments.database_count,
|
||||
arguments.query_count,
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
write_json(
|
||||
output,
|
||||
self_map,
|
||||
arguments.threshold
|
||||
);
|
||||
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr
|
||||
<< "[face_knn] errore: "
|
||||
<< error.what()
|
||||
<< "\n";
|
||||
|
||||
print_usage(argv[0]);
|
||||
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
144
docs/face-knn-source/src/face_recognition.cc
Normal file
144
docs/face-knn-source/src/face_recognition.cc
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
#include <cstddef>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "face_engine.h"
|
||||
#include "face_config.h"
|
||||
|
||||
static constexpr float MATCH_THRESHOLD = 0.363f;
|
||||
|
||||
static float cosine(
|
||||
const std::vector<float>& a,
|
||||
const std::vector<float>& b)
|
||||
{
|
||||
if (a.size() != b.size())
|
||||
return 0.0f;
|
||||
|
||||
float score = 0.0f;
|
||||
|
||||
for (std::size_t i = 0; i < a.size(); ++i)
|
||||
score += a[i] * b[i];
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
static void print_faces(
|
||||
const std::string& image_path,
|
||||
const std::vector<Face>& faces)
|
||||
{
|
||||
std::cout << "\n" << image_path << "\n";
|
||||
std::cout << "Faces: " << faces.size() << "\n";
|
||||
|
||||
for (std::size_t i = 0; i < faces.size(); ++i) {
|
||||
const Face& face = faces[i];
|
||||
|
||||
std::cout
|
||||
<< "\nFace " << i
|
||||
<< " score=" << face.score
|
||||
<< "\n bbox="
|
||||
<< face.x1 << " "
|
||||
<< face.y1 << " "
|
||||
<< face.x2 << " "
|
||||
<< face.y2
|
||||
<< "\n";
|
||||
|
||||
std::cout << " landmarks:\n";
|
||||
|
||||
for (int j = 0; j < 5; ++j) {
|
||||
std::cout
|
||||
<< " "
|
||||
<< j
|
||||
<< ": "
|
||||
<< face.kps[j].x
|
||||
<< " "
|
||||
<< face.kps[j].y
|
||||
<< "\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
if (argc != 2 && argc != 3) {
|
||||
std::cerr
|
||||
<< "Uso:\n"
|
||||
<< " " << argv[0]
|
||||
<< " image.jpg\n"
|
||||
<< " " << argv[0]
|
||||
<< " reference.jpg query.jpg\n";
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
const std::string root =
|
||||
FACE_CLUSTERING_ROOT;
|
||||
|
||||
const std::string scrfd_model =
|
||||
root + "/models/SCRFD_500M_KPS_640.rknn";
|
||||
|
||||
const std::string sface_model =
|
||||
root + "/models/face_recognition_sface_2021dec.rknn";
|
||||
|
||||
FaceEngine engine;
|
||||
|
||||
if (!engine.init(scrfd_model, sface_model))
|
||||
return 1;
|
||||
|
||||
std::vector<Face> faces1;
|
||||
int image1_width = 0;
|
||||
int image1_height = 0;
|
||||
|
||||
if (!engine.process_image(
|
||||
argv[1],
|
||||
faces1,
|
||||
image1_width,
|
||||
image1_height)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
print_faces(argv[1], faces1);
|
||||
|
||||
if (argc == 2)
|
||||
return 0;
|
||||
|
||||
std::vector<Face> faces2;
|
||||
int image2_width = 0;
|
||||
int image2_height = 0;
|
||||
|
||||
if (!engine.process_image(
|
||||
argv[2],
|
||||
faces2,
|
||||
image2_width,
|
||||
image2_height)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
print_faces(argv[2], faces2);
|
||||
|
||||
std::cout
|
||||
<< "\n========================================\n"
|
||||
<< "COSINE SIMILARITY\n"
|
||||
<< "========================================\n";
|
||||
|
||||
for (std::size_t i = 0; i < faces1.size(); ++i) {
|
||||
for (std::size_t j = 0; j < faces2.size(); ++j) {
|
||||
const float similarity = cosine(
|
||||
faces1[i].embedding,
|
||||
faces2[j].embedding);
|
||||
|
||||
std::cout
|
||||
<< "ref[" << i
|
||||
<< "] vs query[" << j
|
||||
<< "] = "
|
||||
<< similarity;
|
||||
|
||||
if (similarity >= MATCH_THRESHOLD)
|
||||
std::cout << " MATCH";
|
||||
|
||||
std::cout << "\n";
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
368
docs/face-knn-source/src/face_scan.cc
Normal file
368
docs/face-knn-source/src/face_scan.cc
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
#include <cmath>
|
||||
#include <cstddef>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <iomanip>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <unistd.h>
|
||||
|
||||
#include "face_engine.h"
|
||||
|
||||
static constexpr std::size_t EMBEDDING_SIZE = 128;
|
||||
|
||||
static std::filesystem::path executable_path()
|
||||
{
|
||||
std::vector<char> buffer(4096);
|
||||
|
||||
while (true) {
|
||||
const ssize_t length = readlink(
|
||||
"/proc/self/exe",
|
||||
buffer.data(),
|
||||
buffer.size() - 1);
|
||||
|
||||
if (length < 0) {
|
||||
throw std::runtime_error(
|
||||
"Impossibile leggere /proc/self/exe");
|
||||
}
|
||||
|
||||
const std::size_t path_length =
|
||||
static_cast<std::size_t>(length);
|
||||
|
||||
if (path_length < buffer.size() - 1) {
|
||||
buffer[path_length] = '\0';
|
||||
|
||||
return std::filesystem::path(
|
||||
buffer.data());
|
||||
}
|
||||
|
||||
buffer.resize(buffer.size() * 2);
|
||||
}
|
||||
}
|
||||
|
||||
static std::filesystem::path application_root()
|
||||
{
|
||||
/*
|
||||
* Struttura prevista:
|
||||
*
|
||||
* root/
|
||||
* bin/face_scan
|
||||
* lib/librknnrt.so
|
||||
* models/<model>.rknn
|
||||
*
|
||||
* executable_path():
|
||||
* root/bin/face_scan
|
||||
*
|
||||
* parent_path():
|
||||
* root/bin
|
||||
*
|
||||
* parent_path().parent_path():
|
||||
* root
|
||||
*/
|
||||
return executable_path()
|
||||
.parent_path()
|
||||
.parent_path();
|
||||
}
|
||||
|
||||
static void write_json_string(const std::string& value)
|
||||
{
|
||||
std::cout << '"';
|
||||
|
||||
for (unsigned char ch : value) {
|
||||
switch (ch) {
|
||||
case '"':
|
||||
std::cout << "\\\"";
|
||||
break;
|
||||
|
||||
case '\\':
|
||||
std::cout << "\\\\";
|
||||
break;
|
||||
|
||||
case '\b':
|
||||
std::cout << "\\b";
|
||||
break;
|
||||
|
||||
case '\f':
|
||||
std::cout << "\\f";
|
||||
break;
|
||||
|
||||
case '\n':
|
||||
std::cout << "\\n";
|
||||
break;
|
||||
|
||||
case '\r':
|
||||
std::cout << "\\r";
|
||||
break;
|
||||
|
||||
case '\t':
|
||||
std::cout << "\\t";
|
||||
break;
|
||||
|
||||
default:
|
||||
if (ch < 0x20) {
|
||||
const char* hex = "0123456789abcdef";
|
||||
|
||||
std::cout
|
||||
<< "\\u00"
|
||||
<< hex[(ch >> 4) & 0x0f]
|
||||
<< hex[ch & 0x0f];
|
||||
} else {
|
||||
std::cout << static_cast<char>(ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::cout << '"';
|
||||
}
|
||||
|
||||
static bool finite_face(const Face& face)
|
||||
{
|
||||
if (!std::isfinite(face.score) ||
|
||||
!std::isfinite(face.x1) ||
|
||||
!std::isfinite(face.y1) ||
|
||||
!std::isfinite(face.x2) ||
|
||||
!std::isfinite(face.y2)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const Point& point : face.kps) {
|
||||
if (!std::isfinite(point.x) ||
|
||||
!std::isfinite(point.y)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (face.embedding.size() != EMBEDDING_SIZE)
|
||||
return false;
|
||||
|
||||
for (float value : face.embedding) {
|
||||
if (!std::isfinite(value))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static void write_face_json(
|
||||
const Face& face,
|
||||
std::size_t face_index)
|
||||
{
|
||||
const float width = face.x2 - face.x1;
|
||||
const float height = face.y2 - face.y1;
|
||||
|
||||
std::cout << "{";
|
||||
|
||||
std::cout
|
||||
<< "\"face_index\":"
|
||||
<< face_index
|
||||
<< ",";
|
||||
|
||||
std::cout
|
||||
<< "\"score\":"
|
||||
<< face.score
|
||||
<< ",";
|
||||
|
||||
std::cout << "\"bbox\":{";
|
||||
|
||||
std::cout
|
||||
<< "\"x\":"
|
||||
<< face.x1
|
||||
<< ",";
|
||||
|
||||
std::cout
|
||||
<< "\"y\":"
|
||||
<< face.y1
|
||||
<< ",";
|
||||
|
||||
std::cout
|
||||
<< "\"width\":"
|
||||
<< width
|
||||
<< ",";
|
||||
|
||||
std::cout
|
||||
<< "\"height\":"
|
||||
<< height;
|
||||
|
||||
std::cout << "},";
|
||||
|
||||
std::cout << "\"landmarks\":[";
|
||||
|
||||
for (int i = 0; i < 5; ++i) {
|
||||
if (i != 0)
|
||||
std::cout << ",";
|
||||
|
||||
std::cout
|
||||
<< "["
|
||||
<< face.kps[i].x
|
||||
<< ","
|
||||
<< face.kps[i].y
|
||||
<< "]";
|
||||
}
|
||||
|
||||
std::cout << "],";
|
||||
|
||||
std::cout << "\"embedding\":[";
|
||||
|
||||
for (std::size_t i = 0;
|
||||
i < face.embedding.size();
|
||||
++i) {
|
||||
if (i != 0)
|
||||
std::cout << ",";
|
||||
|
||||
std::cout << face.embedding[i];
|
||||
}
|
||||
|
||||
std::cout << "]";
|
||||
|
||||
std::cout << "}";
|
||||
}
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
int rotation_degrees = 0;
|
||||
const char* image_path = nullptr;
|
||||
|
||||
if (argc == 2) {
|
||||
image_path = argv[1];
|
||||
} else if (
|
||||
argc == 4
|
||||
&& std::string(argv[1]) == "--rotation"
|
||||
) {
|
||||
char* parse_end = nullptr;
|
||||
|
||||
const long parsed_rotation = std::strtol(
|
||||
argv[2],
|
||||
&parse_end,
|
||||
10);
|
||||
|
||||
if (
|
||||
parse_end == argv[2]
|
||||
|| *parse_end != '\0'
|
||||
|| parsed_rotation < -360000
|
||||
|| parsed_rotation > 360000
|
||||
) {
|
||||
std::cerr
|
||||
<< "Rotazione non valida: "
|
||||
<< argv[2]
|
||||
<< "\n";
|
||||
|
||||
return 2;
|
||||
}
|
||||
|
||||
rotation_degrees =
|
||||
static_cast<int>(parsed_rotation);
|
||||
|
||||
image_path = argv[3];
|
||||
} else {
|
||||
std::cerr
|
||||
<< "Uso:\n"
|
||||
<< " " << argv[0]
|
||||
<< " image.jpg\n"
|
||||
<< " " << argv[0]
|
||||
<< " --rotation 90 image.jpg\n";
|
||||
|
||||
return 2;
|
||||
}
|
||||
|
||||
std::filesystem::path root;
|
||||
|
||||
try {
|
||||
root = application_root();
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr
|
||||
<< "Impossibile determinare la root applicativa: "
|
||||
<< error.what()
|
||||
<< "\n";
|
||||
|
||||
return 3;
|
||||
}
|
||||
|
||||
const std::string scrfd_model =
|
||||
(
|
||||
root
|
||||
/ "models"
|
||||
/ "SCRFD_500M_KPS_640.rknn"
|
||||
).string();
|
||||
|
||||
const std::string sface_model =
|
||||
(
|
||||
root
|
||||
/ "models"
|
||||
/ "face_recognition_sface_2021dec.rknn"
|
||||
).string();
|
||||
|
||||
FaceEngine engine;
|
||||
|
||||
if (!engine.init(scrfd_model, sface_model)) {
|
||||
std::cerr
|
||||
<< "Impossibile inizializzare FaceEngine\n";
|
||||
|
||||
return 3;
|
||||
}
|
||||
|
||||
std::vector<Face> faces;
|
||||
int image_width = 0;
|
||||
int image_height = 0;
|
||||
|
||||
if (!engine.process_image(
|
||||
image_path,
|
||||
faces,
|
||||
image_width,
|
||||
image_height,
|
||||
rotation_degrees)) {
|
||||
std::cerr
|
||||
<< "Scansione fallita: "
|
||||
<< image_path
|
||||
<< "\n";
|
||||
|
||||
return 4;
|
||||
}
|
||||
|
||||
for (std::size_t i = 0; i < faces.size(); ++i) {
|
||||
if (!finite_face(faces[i])) {
|
||||
std::cerr
|
||||
<< "Risultato non valido per face_index="
|
||||
<< i
|
||||
<< "\n";
|
||||
|
||||
return 5;
|
||||
}
|
||||
}
|
||||
|
||||
std::cout << std::setprecision(9);
|
||||
|
||||
std::cout << "{";
|
||||
|
||||
std::cout << "\"image\":{";
|
||||
|
||||
std::cout << "\"path\":";
|
||||
write_json_string(image_path);
|
||||
|
||||
std::cout
|
||||
<< ",\"width\":"
|
||||
<< image_width
|
||||
<< ",\"height\":"
|
||||
<< image_height
|
||||
<< ",\"rotation\":"
|
||||
<< rotation_degrees;
|
||||
|
||||
std::cout << "},";
|
||||
|
||||
std::cout << "\"faces\":[";
|
||||
|
||||
for (std::size_t i = 0; i < faces.size(); ++i) {
|
||||
if (i != 0)
|
||||
std::cout << ",";
|
||||
|
||||
write_face_json(faces[i], i);
|
||||
}
|
||||
|
||||
std::cout << "]";
|
||||
|
||||
std::cout << "}\n";
|
||||
|
||||
return 0;
|
||||
}
|
||||
484
docs/face-knn-source/src/knn_opencl.cc
Normal file
484
docs/face-knn-source/src/knn_opencl.cc
Normal file
|
|
@ -0,0 +1,484 @@
|
|||
#include "knn_opencl.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace face_knn {
|
||||
|
||||
SearchOutput search_opencl_impl(
|
||||
const std::vector<float>& database,
|
||||
const std::vector<float>& queries,
|
||||
std::size_t database_count,
|
||||
std::size_t query_count,
|
||||
const SearchOptions& options);
|
||||
|
||||
|
||||
namespace {
|
||||
|
||||
bool better_neighbor(
|
||||
const Neighbor& left,
|
||||
const Neighbor& right)
|
||||
{
|
||||
if (left.score != right.score) {
|
||||
return left.score > right.score;
|
||||
}
|
||||
|
||||
return left.index < right.index;
|
||||
}
|
||||
|
||||
|
||||
void validate_finite_values(
|
||||
const std::vector<float>& values,
|
||||
const char* name)
|
||||
{
|
||||
for (
|
||||
std::size_t index = 0;
|
||||
index < values.size();
|
||||
++index
|
||||
) {
|
||||
if (!std::isfinite(values[index])) {
|
||||
throw std::invalid_argument(
|
||||
std::string(name)
|
||||
+ " contiene un valore non finito"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
std::uint32_t effective_top_k(
|
||||
std::size_t database_count,
|
||||
std::uint32_t requested_top_k)
|
||||
{
|
||||
return std::min(
|
||||
requested_top_k,
|
||||
static_cast<std::uint32_t>(
|
||||
database_count
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
|
||||
void validate_input(
|
||||
const std::vector<float>& database,
|
||||
const std::vector<float>& queries,
|
||||
std::size_t database_count,
|
||||
std::size_t query_count,
|
||||
const SearchOptions& options)
|
||||
{
|
||||
if (options.dimension == 0) {
|
||||
throw std::invalid_argument(
|
||||
"dimension deve essere maggiore di zero"
|
||||
);
|
||||
}
|
||||
|
||||
if (options.top_k == 0) {
|
||||
throw std::invalid_argument(
|
||||
"top_k deve essere maggiore di zero"
|
||||
);
|
||||
}
|
||||
|
||||
if (options.lanes_per_query == 0) {
|
||||
throw std::invalid_argument(
|
||||
"lanes_per_query deve essere maggiore di zero"
|
||||
);
|
||||
}
|
||||
|
||||
if (options.chunk_size == 0) {
|
||||
throw std::invalid_argument(
|
||||
"chunk_size deve essere maggiore di zero"
|
||||
);
|
||||
}
|
||||
|
||||
if (database_count == 0) {
|
||||
throw std::invalid_argument(
|
||||
"database_count deve essere maggiore di zero"
|
||||
);
|
||||
}
|
||||
|
||||
if (query_count == 0) {
|
||||
throw std::invalid_argument(
|
||||
"query_count deve essere maggiore di zero"
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
database_count
|
||||
> std::numeric_limits<
|
||||
std::uint32_t
|
||||
>::max()
|
||||
) {
|
||||
throw std::invalid_argument(
|
||||
"database_count supera uint32"
|
||||
);
|
||||
}
|
||||
|
||||
const std::size_t expected_database =
|
||||
database_count
|
||||
* static_cast<std::size_t>(
|
||||
options.dimension
|
||||
);
|
||||
|
||||
const std::size_t expected_queries =
|
||||
query_count
|
||||
* static_cast<std::size_t>(
|
||||
options.dimension
|
||||
);
|
||||
|
||||
if (database.size() != expected_database) {
|
||||
throw std::invalid_argument(
|
||||
"dimensione database incoerente"
|
||||
);
|
||||
}
|
||||
|
||||
if (queries.size() != expected_queries) {
|
||||
throw std::invalid_argument(
|
||||
"dimensione query incoerente"
|
||||
);
|
||||
}
|
||||
|
||||
validate_finite_values(
|
||||
database,
|
||||
"database"
|
||||
);
|
||||
|
||||
validate_finite_values(
|
||||
queries,
|
||||
"queries"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
SearchOutput search_cpu(
|
||||
const std::vector<float>& database,
|
||||
const std::vector<float>& queries,
|
||||
std::size_t database_count,
|
||||
std::size_t query_count,
|
||||
const SearchOptions& options)
|
||||
{
|
||||
validate_input(
|
||||
database,
|
||||
queries,
|
||||
database_count,
|
||||
query_count,
|
||||
options
|
||||
);
|
||||
|
||||
const std::uint32_t result_top_k =
|
||||
effective_top_k(
|
||||
database_count,
|
||||
options.top_k
|
||||
);
|
||||
|
||||
SearchOutput output;
|
||||
|
||||
output.query_count = query_count;
|
||||
output.database_count = database_count;
|
||||
output.dimension = options.dimension;
|
||||
output.top_k = result_top_k;
|
||||
output.backend = "cpu";
|
||||
output.device_name = "CPU";
|
||||
|
||||
output.neighbors.resize(
|
||||
query_count
|
||||
* static_cast<std::size_t>(
|
||||
result_top_k
|
||||
)
|
||||
);
|
||||
|
||||
const auto started_at =
|
||||
std::chrono::steady_clock::now();
|
||||
|
||||
for (
|
||||
std::size_t query_index = 0;
|
||||
query_index < query_count;
|
||||
++query_index
|
||||
) {
|
||||
const float* query =
|
||||
&queries[
|
||||
query_index
|
||||
* options.dimension
|
||||
];
|
||||
|
||||
std::vector<Neighbor> candidates;
|
||||
|
||||
candidates.reserve(database_count);
|
||||
|
||||
for (
|
||||
std::size_t database_index = 0;
|
||||
database_index < database_count;
|
||||
++database_index
|
||||
) {
|
||||
const float* database_vector =
|
||||
&database[
|
||||
database_index
|
||||
* options.dimension
|
||||
];
|
||||
|
||||
float score = 0.0f;
|
||||
|
||||
for (
|
||||
std::uint32_t component = 0;
|
||||
component < options.dimension;
|
||||
++component
|
||||
) {
|
||||
score +=
|
||||
database_vector[component]
|
||||
* query[component];
|
||||
}
|
||||
|
||||
candidates.push_back({
|
||||
score,
|
||||
static_cast<std::uint32_t>(
|
||||
database_index
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
result_top_k
|
||||
< candidates.size()
|
||||
) {
|
||||
std::nth_element(
|
||||
candidates.begin(),
|
||||
candidates.begin()
|
||||
+ result_top_k,
|
||||
candidates.end(),
|
||||
better_neighbor
|
||||
);
|
||||
}
|
||||
|
||||
std::sort(
|
||||
candidates.begin(),
|
||||
candidates.begin()
|
||||
+ result_top_k,
|
||||
better_neighbor
|
||||
);
|
||||
|
||||
const std::size_t output_offset =
|
||||
query_index
|
||||
* result_top_k;
|
||||
|
||||
for (
|
||||
std::uint32_t position = 0;
|
||||
position < result_top_k;
|
||||
++position
|
||||
) {
|
||||
output.neighbors[
|
||||
output_offset + position
|
||||
] = candidates[position];
|
||||
}
|
||||
}
|
||||
|
||||
const auto completed_at =
|
||||
std::chrono::steady_clock::now();
|
||||
|
||||
output.elapsed_ms =
|
||||
std::chrono::duration<
|
||||
double,
|
||||
std::milli
|
||||
>(
|
||||
completed_at - started_at
|
||||
).count();
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
|
||||
SearchOutput search_opencl(
|
||||
const std::vector<float>& database,
|
||||
const std::vector<float>& queries,
|
||||
std::size_t database_count,
|
||||
std::size_t query_count,
|
||||
const SearchOptions& options)
|
||||
{
|
||||
return search_opencl_impl(
|
||||
database,
|
||||
queries,
|
||||
database_count,
|
||||
query_count,
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
std::vector<Neighbor> query_neighbors(
|
||||
const SearchOutput& output,
|
||||
std::size_t query_index)
|
||||
{
|
||||
if (query_index >= output.query_count) {
|
||||
throw std::out_of_range(
|
||||
"query_index fuori intervallo"
|
||||
);
|
||||
}
|
||||
|
||||
const std::size_t begin =
|
||||
query_index
|
||||
* output.top_k;
|
||||
|
||||
const std::size_t end =
|
||||
begin
|
||||
+ output.top_k;
|
||||
|
||||
if (end > output.neighbors.size()) {
|
||||
throw std::runtime_error(
|
||||
"layout SearchOutput incoerente"
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
output.neighbors.begin() + begin,
|
||||
output.neighbors.begin() + end,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
std::vector<Neighbor> exclude_neighbor(
|
||||
const std::vector<Neighbor>& neighbors,
|
||||
std::uint32_t excluded_index)
|
||||
{
|
||||
std::vector<Neighbor> filtered;
|
||||
|
||||
filtered.reserve(neighbors.size());
|
||||
|
||||
for (const Neighbor& neighbor : neighbors) {
|
||||
if (neighbor.index == invalid_index) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (neighbor.index == excluded_index) {
|
||||
continue;
|
||||
}
|
||||
|
||||
filtered.push_back(neighbor);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
|
||||
ValidationResult compare_outputs(
|
||||
const SearchOutput& expected,
|
||||
const SearchOutput& actual,
|
||||
float score_tolerance,
|
||||
float recognition_threshold)
|
||||
{
|
||||
if (score_tolerance < 0.0f) {
|
||||
throw std::invalid_argument(
|
||||
"score_tolerance non valida"
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
expected.query_count
|
||||
!= actual.query_count
|
||||
|| expected.database_count
|
||||
!= actual.database_count
|
||||
|| expected.dimension
|
||||
!= actual.dimension
|
||||
|| expected.top_k
|
||||
!= actual.top_k
|
||||
|| expected.neighbors.size()
|
||||
!= actual.neighbors.size()
|
||||
) {
|
||||
throw std::invalid_argument(
|
||||
"Output kNN non confrontabili"
|
||||
);
|
||||
}
|
||||
|
||||
ValidationResult result;
|
||||
|
||||
result.positions =
|
||||
expected.neighbors.size();
|
||||
|
||||
for (
|
||||
std::size_t index = 0;
|
||||
index < result.positions;
|
||||
++index
|
||||
) {
|
||||
const Neighbor& expected_neighbor =
|
||||
expected.neighbors[index];
|
||||
|
||||
const Neighbor& actual_neighbor =
|
||||
actual.neighbors[index];
|
||||
|
||||
if (
|
||||
expected_neighbor.index
|
||||
== actual_neighbor.index
|
||||
) {
|
||||
result.exact_index_matches += 1;
|
||||
}
|
||||
|
||||
const float error = std::fabs(
|
||||
expected_neighbor.score
|
||||
- actual_neighbor.score
|
||||
);
|
||||
|
||||
result.maximum_score_error =
|
||||
std::max(
|
||||
result.maximum_score_error,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
for (
|
||||
std::size_t query_index = 0;
|
||||
query_index < expected.query_count;
|
||||
++query_index
|
||||
) {
|
||||
const auto expected_neighbors =
|
||||
query_neighbors(
|
||||
expected,
|
||||
query_index
|
||||
);
|
||||
|
||||
const auto actual_neighbors =
|
||||
query_neighbors(
|
||||
actual,
|
||||
query_index
|
||||
);
|
||||
|
||||
const bool expected_recognized =
|
||||
!expected_neighbors.empty()
|
||||
&& expected_neighbors.front().score
|
||||
>= recognition_threshold;
|
||||
|
||||
const bool actual_recognized =
|
||||
!actual_neighbors.empty()
|
||||
&& actual_neighbors.front().score
|
||||
>= recognition_threshold;
|
||||
|
||||
if (
|
||||
expected_recognized
|
||||
== actual_recognized
|
||||
) {
|
||||
result.decision_matches += 1;
|
||||
}
|
||||
}
|
||||
|
||||
result.exact_indices =
|
||||
result.exact_index_matches
|
||||
== result.positions;
|
||||
|
||||
result.decisions_equal =
|
||||
result.decision_matches
|
||||
== expected.query_count;
|
||||
|
||||
if (
|
||||
result.maximum_score_error
|
||||
> score_tolerance
|
||||
) {
|
||||
result.decisions_equal = false;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace face_knn
|
||||
944
docs/face-knn-source/src/knn_opencl_backend.cc
Normal file
944
docs/face-knn-source/src/knn_opencl_backend.cc
Normal file
|
|
@ -0,0 +1,944 @@
|
|||
#define CL_TARGET_OPENCL_VERSION 120
|
||||
|
||||
#include <CL/cl.h>
|
||||
|
||||
#include "knn_opencl.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <iterator>
|
||||
#include <limits>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace face_knn {
|
||||
namespace {
|
||||
|
||||
void check_cl(
|
||||
cl_int status,
|
||||
const char* operation)
|
||||
{
|
||||
if (status != CL_SUCCESS) {
|
||||
throw std::runtime_error(
|
||||
std::string(operation)
|
||||
+ ": OpenCL status "
|
||||
+ std::to_string(status)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
std::string device_string(
|
||||
cl_device_id device,
|
||||
cl_device_info property)
|
||||
{
|
||||
size_t size = 0;
|
||||
|
||||
check_cl(
|
||||
clGetDeviceInfo(
|
||||
device,
|
||||
property,
|
||||
0,
|
||||
nullptr,
|
||||
&size
|
||||
),
|
||||
"clGetDeviceInfo size"
|
||||
);
|
||||
|
||||
std::string value(
|
||||
size,
|
||||
'\0'
|
||||
);
|
||||
|
||||
check_cl(
|
||||
clGetDeviceInfo(
|
||||
device,
|
||||
property,
|
||||
size,
|
||||
value.data(),
|
||||
nullptr
|
||||
),
|
||||
"clGetDeviceInfo value"
|
||||
);
|
||||
|
||||
while (
|
||||
!value.empty()
|
||||
&& value.back() == '\0'
|
||||
) {
|
||||
value.pop_back();
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
cl_device_id find_mali_device(
|
||||
std::string& device_name)
|
||||
{
|
||||
cl_uint platform_count = 0;
|
||||
|
||||
check_cl(
|
||||
clGetPlatformIDs(
|
||||
0,
|
||||
nullptr,
|
||||
&platform_count
|
||||
),
|
||||
"clGetPlatformIDs count"
|
||||
);
|
||||
|
||||
if (platform_count == 0) {
|
||||
throw std::runtime_error(
|
||||
"Nessuna piattaforma OpenCL"
|
||||
);
|
||||
}
|
||||
|
||||
std::vector<cl_platform_id> platforms(
|
||||
platform_count
|
||||
);
|
||||
|
||||
check_cl(
|
||||
clGetPlatformIDs(
|
||||
platform_count,
|
||||
platforms.data(),
|
||||
nullptr
|
||||
),
|
||||
"clGetPlatformIDs list"
|
||||
);
|
||||
|
||||
cl_device_id fallback = nullptr;
|
||||
std::string fallback_name;
|
||||
|
||||
for (
|
||||
cl_platform_id platform
|
||||
: platforms
|
||||
) {
|
||||
cl_uint device_count = 0;
|
||||
|
||||
const cl_int status =
|
||||
clGetDeviceIDs(
|
||||
platform,
|
||||
CL_DEVICE_TYPE_GPU,
|
||||
0,
|
||||
nullptr,
|
||||
&device_count
|
||||
);
|
||||
|
||||
if (
|
||||
status != CL_SUCCESS
|
||||
|| device_count == 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
std::vector<cl_device_id> devices(
|
||||
device_count
|
||||
);
|
||||
|
||||
check_cl(
|
||||
clGetDeviceIDs(
|
||||
platform,
|
||||
CL_DEVICE_TYPE_GPU,
|
||||
device_count,
|
||||
devices.data(),
|
||||
nullptr
|
||||
),
|
||||
"clGetDeviceIDs list"
|
||||
);
|
||||
|
||||
for (
|
||||
cl_device_id device
|
||||
: devices
|
||||
) {
|
||||
const std::string name =
|
||||
device_string(
|
||||
device,
|
||||
CL_DEVICE_NAME
|
||||
);
|
||||
|
||||
const std::string vendor =
|
||||
device_string(
|
||||
device,
|
||||
CL_DEVICE_VENDOR
|
||||
);
|
||||
|
||||
const std::string full_name =
|
||||
vendor + " / " + name;
|
||||
|
||||
if (fallback == nullptr) {
|
||||
fallback = device;
|
||||
fallback_name = full_name;
|
||||
}
|
||||
|
||||
if (
|
||||
name.find("Mali-G610")
|
||||
!= std::string::npos
|
||||
) {
|
||||
device_name = full_name;
|
||||
return device;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fallback != nullptr) {
|
||||
device_name = fallback_name;
|
||||
|
||||
std::cerr
|
||||
<< "[face_knn] Mali-G610 non trovata; "
|
||||
<< "uso GPU OpenCL: "
|
||||
<< fallback_name
|
||||
<< "\n";
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
throw std::runtime_error(
|
||||
"Nessuna GPU OpenCL disponibile"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
std::string read_text_file(
|
||||
const std::string& path)
|
||||
{
|
||||
std::ifstream input(path);
|
||||
|
||||
if (!input) {
|
||||
throw std::runtime_error(
|
||||
"Kernel OpenCL non leggibile: "
|
||||
+ path
|
||||
);
|
||||
}
|
||||
|
||||
const std::string content{
|
||||
std::istreambuf_iterator<char>(
|
||||
input
|
||||
),
|
||||
std::istreambuf_iterator<char>()
|
||||
};
|
||||
|
||||
if (content.empty()) {
|
||||
throw std::runtime_error(
|
||||
"Kernel OpenCL vuoto: "
|
||||
+ path
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
|
||||
bool better_neighbor(
|
||||
const Neighbor& left,
|
||||
const Neighbor& right)
|
||||
{
|
||||
if (left.score != right.score) {
|
||||
return left.score > right.score;
|
||||
}
|
||||
|
||||
return left.index < right.index;
|
||||
}
|
||||
|
||||
|
||||
Neighbor invalid_neighbor()
|
||||
{
|
||||
return {
|
||||
-std::numeric_limits<float>::infinity(),
|
||||
invalid_index,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
void merge_top_k(
|
||||
const std::vector<std::uint32_t>& chunk_indices,
|
||||
const std::vector<float>& chunk_scores,
|
||||
std::size_t chunk_offset,
|
||||
std::size_t query_count,
|
||||
std::uint32_t top_k,
|
||||
std::vector<Neighbor>& global_result)
|
||||
{
|
||||
for (
|
||||
std::size_t query_index = 0;
|
||||
query_index < query_count;
|
||||
++query_index
|
||||
) {
|
||||
const std::size_t result_offset =
|
||||
query_index
|
||||
* top_k;
|
||||
|
||||
std::vector<Neighbor> candidates;
|
||||
|
||||
candidates.reserve(
|
||||
static_cast<std::size_t>(
|
||||
top_k
|
||||
) * 2
|
||||
);
|
||||
|
||||
for (
|
||||
std::uint32_t position = 0;
|
||||
position < top_k;
|
||||
++position
|
||||
) {
|
||||
const Neighbor& current =
|
||||
global_result[
|
||||
result_offset
|
||||
+ position
|
||||
];
|
||||
|
||||
if (
|
||||
current.index
|
||||
!= invalid_index
|
||||
) {
|
||||
candidates.push_back(
|
||||
current
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (
|
||||
std::uint32_t position = 0;
|
||||
position < top_k;
|
||||
++position
|
||||
) {
|
||||
const std::size_t chunk_position =
|
||||
result_offset
|
||||
+ position;
|
||||
|
||||
const std::uint32_t local_index =
|
||||
chunk_indices[
|
||||
chunk_position
|
||||
];
|
||||
|
||||
if (
|
||||
local_index
|
||||
== invalid_index
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
candidates.push_back({
|
||||
chunk_scores[
|
||||
chunk_position
|
||||
],
|
||||
static_cast<std::uint32_t>(
|
||||
chunk_offset
|
||||
+ local_index
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
std::sort(
|
||||
candidates.begin(),
|
||||
candidates.end(),
|
||||
better_neighbor
|
||||
);
|
||||
|
||||
for (
|
||||
std::uint32_t position = 0;
|
||||
position < top_k;
|
||||
++position
|
||||
) {
|
||||
Neighbor value =
|
||||
invalid_neighbor();
|
||||
|
||||
if (
|
||||
position
|
||||
< candidates.size()
|
||||
) {
|
||||
value =
|
||||
candidates[position];
|
||||
}
|
||||
|
||||
global_result[
|
||||
result_offset
|
||||
+ position
|
||||
] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
struct OpenCLResources {
|
||||
cl_context context = nullptr;
|
||||
|
||||
cl_command_queue queue = nullptr;
|
||||
|
||||
cl_program program = nullptr;
|
||||
|
||||
cl_kernel kernel = nullptr;
|
||||
|
||||
cl_mem database_buffer = nullptr;
|
||||
|
||||
cl_mem query_buffer = nullptr;
|
||||
|
||||
cl_mem index_buffer = nullptr;
|
||||
|
||||
cl_mem score_buffer = nullptr;
|
||||
|
||||
~OpenCLResources()
|
||||
{
|
||||
if (score_buffer != nullptr) {
|
||||
clReleaseMemObject(
|
||||
score_buffer
|
||||
);
|
||||
}
|
||||
|
||||
if (index_buffer != nullptr) {
|
||||
clReleaseMemObject(
|
||||
index_buffer
|
||||
);
|
||||
}
|
||||
|
||||
if (query_buffer != nullptr) {
|
||||
clReleaseMemObject(
|
||||
query_buffer
|
||||
);
|
||||
}
|
||||
|
||||
if (database_buffer != nullptr) {
|
||||
clReleaseMemObject(
|
||||
database_buffer
|
||||
);
|
||||
}
|
||||
|
||||
if (kernel != nullptr) {
|
||||
clReleaseKernel(kernel);
|
||||
}
|
||||
|
||||
if (program != nullptr) {
|
||||
clReleaseProgram(program);
|
||||
}
|
||||
|
||||
if (queue != nullptr) {
|
||||
clReleaseCommandQueue(queue);
|
||||
}
|
||||
|
||||
if (context != nullptr) {
|
||||
clReleaseContext(context);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
std::string build_log(
|
||||
cl_program program,
|
||||
cl_device_id device)
|
||||
{
|
||||
size_t size = 0;
|
||||
|
||||
clGetProgramBuildInfo(
|
||||
program,
|
||||
device,
|
||||
CL_PROGRAM_BUILD_LOG,
|
||||
0,
|
||||
nullptr,
|
||||
&size
|
||||
);
|
||||
|
||||
std::string value(
|
||||
size,
|
||||
'\0'
|
||||
);
|
||||
|
||||
if (size > 0) {
|
||||
clGetProgramBuildInfo(
|
||||
program,
|
||||
device,
|
||||
CL_PROGRAM_BUILD_LOG,
|
||||
size,
|
||||
value.data(),
|
||||
nullptr
|
||||
);
|
||||
}
|
||||
|
||||
while (
|
||||
!value.empty()
|
||||
&& value.back() == '\0'
|
||||
) {
|
||||
value.pop_back();
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
|
||||
SearchOutput search_opencl_impl(
|
||||
const std::vector<float>& database,
|
||||
const std::vector<float>& queries,
|
||||
std::size_t database_count,
|
||||
std::size_t query_count,
|
||||
const SearchOptions& options)
|
||||
{
|
||||
validate_input(
|
||||
database,
|
||||
queries,
|
||||
database_count,
|
||||
query_count,
|
||||
options
|
||||
);
|
||||
|
||||
if (
|
||||
query_count
|
||||
> std::numeric_limits<
|
||||
std::uint32_t
|
||||
>::max()
|
||||
) {
|
||||
throw std::invalid_argument(
|
||||
"query_count supera uint32"
|
||||
);
|
||||
}
|
||||
|
||||
const std::uint32_t top_k =
|
||||
std::min(
|
||||
options.top_k,
|
||||
static_cast<std::uint32_t>(
|
||||
database_count
|
||||
)
|
||||
);
|
||||
|
||||
const std::string kernel_path =
|
||||
options.kernel_path.empty()
|
||||
? "kernels/knn_float4_topk.cl"
|
||||
: options.kernel_path;
|
||||
|
||||
const std::string kernel_source =
|
||||
read_text_file(
|
||||
kernel_path
|
||||
);
|
||||
|
||||
std::string device_name;
|
||||
|
||||
const cl_device_id device =
|
||||
find_mali_device(
|
||||
device_name
|
||||
);
|
||||
|
||||
std::cerr
|
||||
<< "[face_knn] OpenCL GPU: "
|
||||
<< device_name
|
||||
<< "\n";
|
||||
|
||||
OpenCLResources resources;
|
||||
|
||||
cl_int status = CL_SUCCESS;
|
||||
|
||||
resources.context =
|
||||
clCreateContext(
|
||||
nullptr,
|
||||
1,
|
||||
&device,
|
||||
nullptr,
|
||||
nullptr,
|
||||
&status
|
||||
);
|
||||
|
||||
check_cl(
|
||||
status,
|
||||
"clCreateContext"
|
||||
);
|
||||
|
||||
resources.queue =
|
||||
clCreateCommandQueue(
|
||||
resources.context,
|
||||
device,
|
||||
0,
|
||||
&status
|
||||
);
|
||||
|
||||
check_cl(
|
||||
status,
|
||||
"clCreateCommandQueue"
|
||||
);
|
||||
|
||||
const char* source_pointer =
|
||||
kernel_source.c_str();
|
||||
|
||||
const size_t source_size =
|
||||
kernel_source.size();
|
||||
|
||||
resources.program =
|
||||
clCreateProgramWithSource(
|
||||
resources.context,
|
||||
1,
|
||||
&source_pointer,
|
||||
&source_size,
|
||||
&status
|
||||
);
|
||||
|
||||
check_cl(
|
||||
status,
|
||||
"clCreateProgramWithSource"
|
||||
);
|
||||
|
||||
const std::string build_options =
|
||||
"-cl-fast-relaxed-math"
|
||||
" -D DIM="
|
||||
+ std::to_string(
|
||||
options.dimension
|
||||
)
|
||||
+ " -D TOPK="
|
||||
+ std::to_string(top_k)
|
||||
+ " -D QUERY_COUNT="
|
||||
+ std::to_string(query_count)
|
||||
+ " -D LANES_PER_QUERY="
|
||||
+ std::to_string(
|
||||
options.lanes_per_query
|
||||
);
|
||||
|
||||
status = clBuildProgram(
|
||||
resources.program,
|
||||
1,
|
||||
&device,
|
||||
build_options.c_str(),
|
||||
nullptr,
|
||||
nullptr
|
||||
);
|
||||
|
||||
if (status != CL_SUCCESS) {
|
||||
throw std::runtime_error(
|
||||
"clBuildProgram fallito: "
|
||||
+ build_log(
|
||||
resources.program,
|
||||
device
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
resources.kernel =
|
||||
clCreateKernel(
|
||||
resources.program,
|
||||
"knn",
|
||||
&status
|
||||
);
|
||||
|
||||
check_cl(
|
||||
status,
|
||||
"clCreateKernel"
|
||||
);
|
||||
|
||||
const std::size_t chunk_capacity =
|
||||
std::min(
|
||||
options.chunk_size,
|
||||
database_count
|
||||
);
|
||||
|
||||
resources.database_buffer =
|
||||
clCreateBuffer(
|
||||
resources.context,
|
||||
CL_MEM_READ_ONLY,
|
||||
chunk_capacity
|
||||
* options.dimension
|
||||
* sizeof(float),
|
||||
nullptr,
|
||||
&status
|
||||
);
|
||||
|
||||
check_cl(
|
||||
status,
|
||||
"clCreateBuffer database"
|
||||
);
|
||||
|
||||
resources.query_buffer =
|
||||
clCreateBuffer(
|
||||
resources.context,
|
||||
CL_MEM_READ_ONLY
|
||||
| CL_MEM_COPY_HOST_PTR,
|
||||
queries.size()
|
||||
* sizeof(float),
|
||||
const_cast<float*>(
|
||||
queries.data()
|
||||
),
|
||||
&status
|
||||
);
|
||||
|
||||
check_cl(
|
||||
status,
|
||||
"clCreateBuffer queries"
|
||||
);
|
||||
|
||||
const std::size_t result_count =
|
||||
query_count
|
||||
* static_cast<std::size_t>(
|
||||
top_k
|
||||
);
|
||||
|
||||
std::vector<std::uint32_t> chunk_indices(
|
||||
result_count,
|
||||
invalid_index
|
||||
);
|
||||
|
||||
std::vector<float> chunk_scores(
|
||||
result_count,
|
||||
-std::numeric_limits<float>::infinity()
|
||||
);
|
||||
|
||||
resources.index_buffer =
|
||||
clCreateBuffer(
|
||||
resources.context,
|
||||
CL_MEM_WRITE_ONLY,
|
||||
result_count
|
||||
* sizeof(std::uint32_t),
|
||||
nullptr,
|
||||
&status
|
||||
);
|
||||
|
||||
check_cl(
|
||||
status,
|
||||
"clCreateBuffer indices"
|
||||
);
|
||||
|
||||
resources.score_buffer =
|
||||
clCreateBuffer(
|
||||
resources.context,
|
||||
CL_MEM_WRITE_ONLY,
|
||||
result_count
|
||||
* sizeof(float),
|
||||
nullptr,
|
||||
&status
|
||||
);
|
||||
|
||||
check_cl(
|
||||
status,
|
||||
"clCreateBuffer scores"
|
||||
);
|
||||
|
||||
check_cl(
|
||||
clSetKernelArg(
|
||||
resources.kernel,
|
||||
0,
|
||||
sizeof(
|
||||
resources.database_buffer
|
||||
),
|
||||
&resources.database_buffer
|
||||
),
|
||||
"clSetKernelArg database"
|
||||
);
|
||||
|
||||
check_cl(
|
||||
clSetKernelArg(
|
||||
resources.kernel,
|
||||
1,
|
||||
sizeof(
|
||||
resources.query_buffer
|
||||
),
|
||||
&resources.query_buffer
|
||||
),
|
||||
"clSetKernelArg queries"
|
||||
);
|
||||
|
||||
check_cl(
|
||||
clSetKernelArg(
|
||||
resources.kernel,
|
||||
2,
|
||||
sizeof(
|
||||
resources.index_buffer
|
||||
),
|
||||
&resources.index_buffer
|
||||
),
|
||||
"clSetKernelArg indices"
|
||||
);
|
||||
|
||||
check_cl(
|
||||
clSetKernelArg(
|
||||
resources.kernel,
|
||||
3,
|
||||
sizeof(
|
||||
resources.score_buffer
|
||||
),
|
||||
&resources.score_buffer
|
||||
),
|
||||
"clSetKernelArg scores"
|
||||
);
|
||||
|
||||
SearchOutput output;
|
||||
|
||||
output.query_count =
|
||||
query_count;
|
||||
|
||||
output.database_count =
|
||||
database_count;
|
||||
|
||||
output.dimension =
|
||||
options.dimension;
|
||||
|
||||
output.top_k =
|
||||
top_k;
|
||||
|
||||
output.backend =
|
||||
"opencl";
|
||||
|
||||
output.device_name =
|
||||
device_name;
|
||||
|
||||
output.neighbors.assign(
|
||||
result_count,
|
||||
invalid_neighbor()
|
||||
);
|
||||
|
||||
const std::size_t global_size =
|
||||
query_count
|
||||
* options.lanes_per_query;
|
||||
|
||||
const std::size_t local_size =
|
||||
options.lanes_per_query;
|
||||
|
||||
const std::size_t chunk_count =
|
||||
(
|
||||
database_count
|
||||
+ options.chunk_size
|
||||
- 1
|
||||
)
|
||||
/ options.chunk_size;
|
||||
|
||||
std::cerr
|
||||
<< "[face_knn] kernel_path="
|
||||
<< kernel_path
|
||||
<< "\n"
|
||||
<< "[face_knn] build_options="
|
||||
<< build_options
|
||||
<< "\n"
|
||||
<< "[face_knn] chunk_size="
|
||||
<< chunk_capacity
|
||||
<< "\n"
|
||||
<< "[face_knn] chunks="
|
||||
<< chunk_count
|
||||
<< "\n";
|
||||
|
||||
const auto started_at =
|
||||
std::chrono::steady_clock::now();
|
||||
|
||||
for (
|
||||
std::size_t chunk_offset = 0;
|
||||
chunk_offset < database_count;
|
||||
chunk_offset += options.chunk_size
|
||||
) {
|
||||
const std::size_t chunk_size =
|
||||
std::min(
|
||||
options.chunk_size,
|
||||
database_count
|
||||
- chunk_offset
|
||||
);
|
||||
|
||||
const float* chunk_pointer =
|
||||
&database[
|
||||
chunk_offset
|
||||
* options.dimension
|
||||
];
|
||||
|
||||
check_cl(
|
||||
clEnqueueWriteBuffer(
|
||||
resources.queue,
|
||||
resources.database_buffer,
|
||||
CL_TRUE,
|
||||
0,
|
||||
chunk_size
|
||||
* options.dimension
|
||||
* sizeof(float),
|
||||
chunk_pointer,
|
||||
0,
|
||||
nullptr,
|
||||
nullptr
|
||||
),
|
||||
"clEnqueueWriteBuffer database"
|
||||
);
|
||||
|
||||
const std::uint32_t kernel_count =
|
||||
static_cast<std::uint32_t>(
|
||||
chunk_size
|
||||
);
|
||||
|
||||
check_cl(
|
||||
clSetKernelArg(
|
||||
resources.kernel,
|
||||
4,
|
||||
sizeof(kernel_count),
|
||||
&kernel_count
|
||||
),
|
||||
"clSetKernelArg database count"
|
||||
);
|
||||
|
||||
check_cl(
|
||||
clEnqueueNDRangeKernel(
|
||||
resources.queue,
|
||||
resources.kernel,
|
||||
1,
|
||||
nullptr,
|
||||
&global_size,
|
||||
&local_size,
|
||||
0,
|
||||
nullptr,
|
||||
nullptr
|
||||
),
|
||||
"clEnqueueNDRangeKernel"
|
||||
);
|
||||
|
||||
check_cl(
|
||||
clFinish(
|
||||
resources.queue
|
||||
),
|
||||
"clFinish"
|
||||
);
|
||||
|
||||
check_cl(
|
||||
clEnqueueReadBuffer(
|
||||
resources.queue,
|
||||
resources.index_buffer,
|
||||
CL_TRUE,
|
||||
0,
|
||||
chunk_indices.size()
|
||||
* sizeof(std::uint32_t),
|
||||
chunk_indices.data(),
|
||||
0,
|
||||
nullptr,
|
||||
nullptr
|
||||
),
|
||||
"clEnqueueReadBuffer indices"
|
||||
);
|
||||
|
||||
check_cl(
|
||||
clEnqueueReadBuffer(
|
||||
resources.queue,
|
||||
resources.score_buffer,
|
||||
CL_TRUE,
|
||||
0,
|
||||
chunk_scores.size()
|
||||
* sizeof(float),
|
||||
chunk_scores.data(),
|
||||
0,
|
||||
nullptr,
|
||||
nullptr
|
||||
),
|
||||
"clEnqueueReadBuffer scores"
|
||||
);
|
||||
|
||||
merge_top_k(
|
||||
chunk_indices,
|
||||
chunk_scores,
|
||||
chunk_offset,
|
||||
query_count,
|
||||
top_k,
|
||||
output.neighbors
|
||||
);
|
||||
}
|
||||
|
||||
const auto completed_at =
|
||||
std::chrono::steady_clock::now();
|
||||
|
||||
output.elapsed_ms =
|
||||
std::chrono::duration<
|
||||
double,
|
||||
std::milli
|
||||
>(
|
||||
completed_at - started_at
|
||||
).count();
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
|
||||
} // namespace face_knn
|
||||
281
docs/face-scan-source/COMPILAZIONE_FACE_SCAN.md
Normal file
281
docs/face-scan-source/COMPILAZIONE_FACE_SCAN.md
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
# Face Scan RK3588 - Guida ai sorgenti e compilazione
|
||||
|
||||
## Repository sorgente
|
||||
|
||||
Repository originale identificato:
|
||||
|
||||
```text
|
||||
/home/nvme/dockerdata/prove/face-clustering-rk3588-knn-opencl-validated
|
||||
```
|
||||
|
||||
Questo repository contiene il motore utilizzato per il Face Scan dei progetti:
|
||||
|
||||
```text
|
||||
s141js → s190js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scopo del programma
|
||||
|
||||
Il binario:
|
||||
|
||||
```text
|
||||
bin/face_scan
|
||||
```
|
||||
|
||||
esegue:
|
||||
|
||||
- rilevamento dei volti nelle fotografie;
|
||||
- estrazione degli embedding facciali;
|
||||
- generazione dell'output JSON utilizzato successivamente dal sistema di riconoscimento.
|
||||
|
||||
Il riconoscimento delle persone non viene eseguito da `face_scan`.
|
||||
|
||||
Il riconoscimento viene effettuato successivamente dal programma:
|
||||
|
||||
```text
|
||||
bin/face_knn
|
||||
```
|
||||
|
||||
che utilizza gli embedding generati da `face_scan`.
|
||||
|
||||
---
|
||||
|
||||
## Sorgenti principali
|
||||
|
||||
### Main Face Scan
|
||||
|
||||
```text
|
||||
src/face_scan.cc
|
||||
```
|
||||
|
||||
Si occupa di:
|
||||
|
||||
- caricamento immagine;
|
||||
- avvio scansione;
|
||||
- generazione output JSON.
|
||||
|
||||
### Motore RKNN
|
||||
|
||||
```text
|
||||
src/face_engine.cc
|
||||
```
|
||||
|
||||
Gestisce:
|
||||
|
||||
- caricamento modello RKNN;
|
||||
- inferenza tramite NPU Rockchip;
|
||||
- estrazione embedding.
|
||||
|
||||
### Header
|
||||
|
||||
```text
|
||||
include/face_engine.h
|
||||
include/face_config.h
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Binari generati
|
||||
|
||||
```text
|
||||
bin/face_scan
|
||||
bin/face_recognition
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Modelli utilizzati
|
||||
|
||||
I modelli sono contenuti nella directory:
|
||||
|
||||
```text
|
||||
models/
|
||||
```
|
||||
|
||||
Verificare i modelli disponibili con:
|
||||
|
||||
```bash
|
||||
find models -type f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Librerie richieste
|
||||
|
||||
Verifica:
|
||||
|
||||
```bash
|
||||
ldd bin/face_scan
|
||||
```
|
||||
|
||||
Dipendenze identificate:
|
||||
|
||||
```text
|
||||
librknnrt.so
|
||||
libpthread.so
|
||||
libdl.so
|
||||
libstdc++.so.6
|
||||
libgcc_s.so.1
|
||||
libc.so.6
|
||||
libm.so.6
|
||||
```
|
||||
|
||||
Dipendenza fondamentale:
|
||||
|
||||
```text
|
||||
lib/librknnrt.so
|
||||
```
|
||||
|
||||
Runtime NPU Rockchip.
|
||||
|
||||
---
|
||||
|
||||
## Compilazione
|
||||
|
||||
Pulizia:
|
||||
|
||||
```bash
|
||||
make clean
|
||||
```
|
||||
|
||||
Compilazione:
|
||||
|
||||
```bash
|
||||
make
|
||||
```
|
||||
|
||||
Il Makefile genera:
|
||||
|
||||
```text
|
||||
bin/face_scan
|
||||
bin/face_recognition
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Comandi di verifica
|
||||
|
||||
Verifica dipendenze:
|
||||
|
||||
```bash
|
||||
ldd bin/face_scan
|
||||
```
|
||||
|
||||
Verifica hash:
|
||||
|
||||
```bash
|
||||
sha256sum bin/face_scan
|
||||
```
|
||||
|
||||
Visualizza simboli:
|
||||
|
||||
```bash
|
||||
nm -C bin/face_scan | head
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Hardware target
|
||||
|
||||
Scheda:
|
||||
|
||||
```text
|
||||
Orange Pi 5 Plus
|
||||
```
|
||||
|
||||
SoC:
|
||||
|
||||
```text
|
||||
Rockchip RK3588
|
||||
```
|
||||
|
||||
Acceleratore:
|
||||
|
||||
```text
|
||||
Rockchip NPU
|
||||
```
|
||||
|
||||
Runtime:
|
||||
|
||||
```text
|
||||
RKNN Runtime
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Relazione con Face KNN
|
||||
|
||||
Pipeline completa:
|
||||
|
||||
```text
|
||||
Foto
|
||||
↓
|
||||
face_scan
|
||||
↓
|
||||
embedding facciali
|
||||
↓
|
||||
face_knn
|
||||
↓
|
||||
riconoscimento persona
|
||||
↓
|
||||
database
|
||||
↓
|
||||
gallery
|
||||
```
|
||||
|
||||
Face Scan:
|
||||
|
||||
```text
|
||||
NPU Rockchip (RKNN)
|
||||
```
|
||||
|
||||
Face KNN:
|
||||
|
||||
```text
|
||||
CPU/OpenCL
|
||||
Mali-G610
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cartelle da conservare
|
||||
|
||||
Per poter ricompilare il Face Scan in futuro conservare:
|
||||
|
||||
```text
|
||||
src/
|
||||
include/
|
||||
models/
|
||||
lib/
|
||||
Makefile
|
||||
```
|
||||
|
||||
In particolare:
|
||||
|
||||
```text
|
||||
src/face_scan.cc
|
||||
src/face_engine.cc
|
||||
include/face_engine.h
|
||||
include/face_config.h
|
||||
lib/librknnrt.so
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Note storiche
|
||||
|
||||
Il repository:
|
||||
|
||||
```text
|
||||
face-clustering-rk3588-knn-opencl-validated
|
||||
```
|
||||
|
||||
è stato identificato come repository sorgente originale del motore Face Scan utilizzato dai progetti s141js → s190js.
|
||||
|
||||
Data documentazione:
|
||||
|
||||
```text
|
||||
2026-10-26
|
||||
```
|
||||
99
docs/face-scan-source/Makefile
Normal file
99
docs/face-scan-source/Makefile
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
CXX ?= g++
|
||||
|
||||
ROOT := $(abspath .)
|
||||
|
||||
CXXFLAGS := \
|
||||
-std=c++17 \
|
||||
-O2 \
|
||||
-Wall \
|
||||
-Wextra \
|
||||
-Wpedantic \
|
||||
-Iinclude \
|
||||
-DFACE_CLUSTERING_ROOT=\"$(ROOT)\"
|
||||
|
||||
LDFLAGS := \
|
||||
-L$(ROOT)/lib \
|
||||
-Wl,-rpath,'$$ORIGIN/../lib'
|
||||
|
||||
LDLIBS := \
|
||||
-lrknnrt \
|
||||
-ldl \
|
||||
-lpthread
|
||||
|
||||
BIN_DIR := bin
|
||||
BUILD_DIR := build
|
||||
|
||||
ENGINE_SOURCE := src/face_engine.cc
|
||||
ENGINE_OBJECT := $(BUILD_DIR)/face_engine.o
|
||||
|
||||
FACE_RECOGNITION_SOURCE := src/face_recognition.cc
|
||||
FACE_RECOGNITION_OBJECT := $(BUILD_DIR)/face_recognition.o
|
||||
FACE_RECOGNITION_BINARY := $(BIN_DIR)/face_recognition
|
||||
|
||||
FACE_SCAN_SOURCE := src/face_scan.cc
|
||||
FACE_SCAN_OBJECT := $(BUILD_DIR)/face_scan.o
|
||||
FACE_SCAN_BINARY := $(BIN_DIR)/face_scan
|
||||
|
||||
.PHONY: all clean test info
|
||||
|
||||
all: \
|
||||
$(FACE_RECOGNITION_BINARY) \
|
||||
$(FACE_SCAN_BINARY)
|
||||
|
||||
$(BUILD_DIR):
|
||||
mkdir -p $(BUILD_DIR)
|
||||
|
||||
$(BIN_DIR):
|
||||
mkdir -p $(BIN_DIR)
|
||||
|
||||
$(ENGINE_OBJECT): $(ENGINE_SOURCE) include/face_engine.h | $(BUILD_DIR)
|
||||
$(CXX) $(CXXFLAGS) -c $< -o $@
|
||||
|
||||
$(FACE_RECOGNITION_OBJECT): \
|
||||
$(FACE_RECOGNITION_SOURCE) \
|
||||
include/face_engine.h \
|
||||
include/face_config.h \
|
||||
| $(BUILD_DIR)
|
||||
$(CXX) $(CXXFLAGS) -c $< -o $@
|
||||
|
||||
$(FACE_SCAN_OBJECT): \
|
||||
$(FACE_SCAN_SOURCE) \
|
||||
include/face_engine.h \
|
||||
include/face_config.h \
|
||||
| $(BUILD_DIR)
|
||||
$(CXX) $(CXXFLAGS) -c $< -o $@
|
||||
|
||||
$(FACE_RECOGNITION_BINARY): \
|
||||
$(ENGINE_OBJECT) \
|
||||
$(FACE_RECOGNITION_OBJECT) \
|
||||
| $(BIN_DIR)
|
||||
$(CXX) $^ $(LDFLAGS) $(LDLIBS) -o $@
|
||||
|
||||
$(FACE_SCAN_BINARY): \
|
||||
$(ENGINE_OBJECT) \
|
||||
$(FACE_SCAN_OBJECT) \
|
||||
| $(BIN_DIR)
|
||||
$(CXX) $^ $(LDFLAGS) $(LDLIBS) -o $@
|
||||
|
||||
test: all
|
||||
./bin/face_recognition \
|
||||
test/test.jpg \
|
||||
test/test3f.jpg
|
||||
./bin/face_scan \
|
||||
test/test3f.jpg \
|
||||
> /tmp/face_scan_test.json
|
||||
python3 -m json.tool \
|
||||
/tmp/face_scan_test.json \
|
||||
> /dev/null
|
||||
@echo "Tests completed successfully"
|
||||
|
||||
info:
|
||||
@echo "ROOT=$(ROOT)"
|
||||
@echo "CXX=$(CXX)"
|
||||
@echo "CXXFLAGS=$(CXXFLAGS)"
|
||||
@echo "LDFLAGS=$(LDFLAGS)"
|
||||
|
||||
clean:
|
||||
rm -rf $(BUILD_DIR)
|
||||
rm -f $(FACE_RECOGNITION_BINARY)
|
||||
rm -f $(FACE_SCAN_BINARY)
|
||||
5
docs/face-scan-source/include/face_config.h
Normal file
5
docs/face-scan-source/include/face_config.h
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
#pragma once
|
||||
|
||||
#ifndef FACE_CLUSTERING_ROOT
|
||||
#define FACE_CLUSTERING_ROOT "."
|
||||
#endif
|
||||
47
docs/face-scan-source/include/face_engine.h
Normal file
47
docs/face-scan-source/include/face_engine.h
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
struct Point {
|
||||
float x = 0.0f;
|
||||
float y = 0.0f;
|
||||
};
|
||||
|
||||
struct Face {
|
||||
float score = 0.0f;
|
||||
|
||||
float x1 = 0.0f;
|
||||
float y1 = 0.0f;
|
||||
float x2 = 0.0f;
|
||||
float y2 = 0.0f;
|
||||
|
||||
Point kps[5]{};
|
||||
|
||||
// SFace: embedding L2-normalizzato da 128 float32.
|
||||
std::vector<float> embedding;
|
||||
};
|
||||
|
||||
class FaceEngine {
|
||||
public:
|
||||
FaceEngine();
|
||||
~FaceEngine();
|
||||
|
||||
FaceEngine(const FaceEngine&) = delete;
|
||||
FaceEngine& operator=(const FaceEngine&) = delete;
|
||||
|
||||
bool init(
|
||||
const std::string& scrfd_model,
|
||||
const std::string& sface_model);
|
||||
|
||||
bool process_image(
|
||||
const std::string& image_path,
|
||||
std::vector<Face>& faces,
|
||||
int& image_width,
|
||||
int& image_height,
|
||||
int rotation_degrees = 0);
|
||||
|
||||
private:
|
||||
struct Impl;
|
||||
Impl* impl_;
|
||||
};
|
||||
121
docs/face-scan-source/include/knn_opencl.h
Normal file
121
docs/face-scan-source/include/knn_opencl.h
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
#ifndef FACE_CLUSTERING_KNN_OPENCL_H
|
||||
#define FACE_CLUSTERING_KNN_OPENCL_H
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace face_knn {
|
||||
|
||||
constexpr std::uint32_t invalid_index =
|
||||
0xffffffffu;
|
||||
|
||||
|
||||
struct Neighbor {
|
||||
float score = 0.0f;
|
||||
|
||||
std::uint32_t index =
|
||||
invalid_index;
|
||||
};
|
||||
|
||||
|
||||
struct SearchOptions {
|
||||
std::uint32_t dimension = 128;
|
||||
|
||||
std::uint32_t top_k = 1;
|
||||
|
||||
std::uint32_t lanes_per_query = 8;
|
||||
|
||||
std::size_t chunk_size = 50000;
|
||||
|
||||
std::string kernel_path;
|
||||
};
|
||||
|
||||
|
||||
struct SearchOutput {
|
||||
std::size_t query_count = 0;
|
||||
|
||||
std::size_t database_count = 0;
|
||||
|
||||
std::uint32_t dimension = 0;
|
||||
|
||||
std::uint32_t top_k = 0;
|
||||
|
||||
std::string backend;
|
||||
|
||||
std::string device_name;
|
||||
|
||||
double elapsed_ms = 0.0;
|
||||
|
||||
/*
|
||||
* Layout:
|
||||
*
|
||||
* neighbors[
|
||||
* query_index * top_k
|
||||
* + neighbor_position
|
||||
* ]
|
||||
*/
|
||||
std::vector<Neighbor> neighbors;
|
||||
};
|
||||
|
||||
|
||||
struct ValidationResult {
|
||||
std::size_t positions = 0;
|
||||
|
||||
std::size_t exact_index_matches = 0;
|
||||
|
||||
std::size_t decision_matches = 0;
|
||||
|
||||
float maximum_score_error = 0.0f;
|
||||
|
||||
bool exact_indices = false;
|
||||
|
||||
bool decisions_equal = false;
|
||||
};
|
||||
|
||||
|
||||
void validate_input(
|
||||
const std::vector<float>& database,
|
||||
const std::vector<float>& queries,
|
||||
std::size_t database_count,
|
||||
std::size_t query_count,
|
||||
const SearchOptions& options);
|
||||
|
||||
|
||||
SearchOutput search_cpu(
|
||||
const std::vector<float>& database,
|
||||
const std::vector<float>& queries,
|
||||
std::size_t database_count,
|
||||
std::size_t query_count,
|
||||
const SearchOptions& options);
|
||||
|
||||
|
||||
SearchOutput search_opencl(
|
||||
const std::vector<float>& database,
|
||||
const std::vector<float>& queries,
|
||||
std::size_t database_count,
|
||||
std::size_t query_count,
|
||||
const SearchOptions& options);
|
||||
|
||||
|
||||
ValidationResult compare_outputs(
|
||||
const SearchOutput& expected,
|
||||
const SearchOutput& actual,
|
||||
float score_tolerance,
|
||||
float recognition_threshold);
|
||||
|
||||
|
||||
std::vector<Neighbor> query_neighbors(
|
||||
const SearchOutput& output,
|
||||
std::size_t query_index);
|
||||
|
||||
|
||||
std::vector<Neighbor> exclude_neighbor(
|
||||
const std::vector<Neighbor>& neighbors,
|
||||
std::uint32_t excluded_index);
|
||||
|
||||
|
||||
} // namespace face_knn
|
||||
|
||||
#endif
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue