Merge pull request #14 from sudolulo/refactor/code-quality
refactor: collapse Config proxy, SQLite tracker, split reconcile, consolidate pyproject
This commit is contained in:
@@ -22,7 +22,7 @@ jobs:
|
||||
run: uv python install 3.13
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --all-extras
|
||||
run: uv sync --extra cpu
|
||||
|
||||
- name: Run tests
|
||||
run: uv run pytest
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# .github/workflows/update-lockfile.yml
|
||||
name: Update lockfiles
|
||||
name: Update lockfile
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -7,9 +7,6 @@ on:
|
||||
- '**'
|
||||
paths:
|
||||
- 'pyproject.toml'
|
||||
- 'pyproject-cpu.toml'
|
||||
- 'pyproject-rocm.toml'
|
||||
- 'pyproject-intel.toml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -18,14 +15,6 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Free up disk space
|
||||
run: |
|
||||
sudo rm -rf /usr/share/dotnet
|
||||
sudo rm -rf /opt/ghc
|
||||
sudo rm -rf "/usr/local/share/boost"
|
||||
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
|
||||
echo "Disk space freed."
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
@@ -35,32 +24,23 @@ jobs:
|
||||
- name: Set up Python
|
||||
run: uv python install 3.13
|
||||
|
||||
- name: Regenerate all lockfiles
|
||||
run: |
|
||||
cp pyproject.toml _pyproject_orig.toml
|
||||
for variant in cpu rocm intel; do
|
||||
cp pyproject-${variant}.toml pyproject.toml
|
||||
uv lock
|
||||
cp uv.lock uv-${variant}.lock
|
||||
done
|
||||
cp _pyproject_orig.toml pyproject.toml
|
||||
uv lock
|
||||
rm _pyproject_orig.toml
|
||||
- name: Regenerate lockfile
|
||||
run: uv lock
|
||||
|
||||
- name: Check for changes
|
||||
id: diff
|
||||
run: |
|
||||
if git diff --quiet uv.lock uv-cpu.lock uv-rocm.lock uv-intel.lock; then
|
||||
if git diff --quiet uv.lock; then
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Commit and push updated lockfiles
|
||||
- name: Commit and push updated lockfile
|
||||
if: steps.diff.outputs.changed == 'true'
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add uv.lock uv-cpu.lock uv-rocm.lock uv-intel.lock
|
||||
git commit -m "chore: update lockfiles"
|
||||
git add uv.lock
|
||||
git commit -m "chore: update lockfile"
|
||||
git push
|
||||
|
||||
+10
-8
@@ -43,18 +43,20 @@ RUN curl -LsSf https://astral.sh/uv/install.sh | sh \
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Swap in the variant-specific pyproject and lockfile before syncing.
|
||||
COPY pyproject.toml uv.lock pyproject-cpu.toml uv-cpu.lock \
|
||||
pyproject-rocm.toml uv-rocm.lock pyproject-intel.toml uv-intel.lock ./
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN if [ "$VARIANT" = "cpu" ]; then \
|
||||
cp pyproject-cpu.toml pyproject.toml && cp uv-cpu.lock uv.lock; \
|
||||
uv sync --frozen --no-dev --extra cpu; \
|
||||
elif [ "$VARIANT" = "rocm" ]; then \
|
||||
cp pyproject-rocm.toml pyproject.toml && cp uv-rocm.lock uv.lock; \
|
||||
uv sync --frozen --no-dev --extra rocm; \
|
||||
elif [ "$VARIANT" = "intel" ]; then \
|
||||
cp pyproject-intel.toml pyproject.toml && cp uv-intel.lock uv.lock; \
|
||||
uv sync --frozen --no-dev --extra intel; \
|
||||
elif [ "$VARIANT" = "gpu" ]; then \
|
||||
uv sync --frozen --no-dev --extra gpu; \
|
||||
else \
|
||||
echo "Unknown VARIANT: '$VARIANT'. Must be one of: cpu, rocm, intel, gpu" >&2; \
|
||||
exit 1; \
|
||||
fi && \
|
||||
uv sync --frozen --no-dev \
|
||||
&& uv cache clean
|
||||
uv cache clean
|
||||
|
||||
COPY winnow/ winnow/
|
||||
COPY entrypoint.sh scheduler.py ./
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
[project]
|
||||
name = "winnow"
|
||||
version = "0.4.11"
|
||||
description = "Selects diverse, high-quality photos from Immich as training data for Frigate face recognition."
|
||||
license = "AGPL-3.0-or-later"
|
||||
requires-python = ">=3.13"
|
||||
authors = [{ name = "Holden Salomon", email = "holden@arch.fyi" }]
|
||||
keywords = ["immich", "frigate", "face-recognition", "training-data", "arcface", "insightface"]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: Scientific/Engineering :: Image Recognition",
|
||||
]
|
||||
dependencies = [
|
||||
"croniter>=5.0.2",
|
||||
"insightface>=0.7.3",
|
||||
"numpy>=2.2.6",
|
||||
"onnxruntime>=1.23.2",
|
||||
"opencv-python-headless>=4.12.0.88",
|
||||
"pillow>=12.1.0",
|
||||
"python-dotenv>=1.2.1",
|
||||
"requests>=2.32.5",
|
||||
"rich>=14.2.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
winnow = "winnow.cli:main"
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/sudolulo/winnow"
|
||||
Changelog = "https://github.com/sudolulo/winnow/blob/main/CHANGELOG.md"
|
||||
|
||||
[tool.uv]
|
||||
required-environments = [
|
||||
"sys_platform == 'linux' and platform_machine == 'x86_64'",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"ruff>=0.15.17",
|
||||
]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["winnow"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py313"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I"]
|
||||
|
||||
[tool.deptry]
|
||||
pep621_dev_dependency_groups = ["dev"]
|
||||
|
||||
[tool.deptry.package_module_name_map]
|
||||
pillow = "PIL"
|
||||
opencv-python-headless = "cv2"
|
||||
python-dotenv = "dotenv"
|
||||
insightface = "insightface"
|
||||
numpy = "numpy"
|
||||
onnxruntime = "onnxruntime"
|
||||
requests = "requests"
|
||||
rich = "rich"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
@@ -1,84 +0,0 @@
|
||||
[project]
|
||||
name = "winnow"
|
||||
version = "0.4.11"
|
||||
description = "Selects diverse, high-quality photos from Immich as training data for Frigate face recognition."
|
||||
license = "AGPL-3.0-or-later"
|
||||
requires-python = ">=3.13"
|
||||
authors = [{ name = "Holden Salomon", email = "holden@arch.fyi" }]
|
||||
keywords = ["immich", "frigate", "face-recognition", "training-data", "arcface", "insightface"]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: Scientific/Engineering :: Image Recognition",
|
||||
]
|
||||
dependencies = [
|
||||
"croniter>=5.0.2",
|
||||
"insightface>=0.7.3",
|
||||
"numpy>=2.2.6",
|
||||
"onnxruntime-openvino>=1.20.0",
|
||||
"opencv-python-headless>=4.12.0.88",
|
||||
"pillow>=12.1.0",
|
||||
"python-dotenv>=1.2.1",
|
||||
"requests>=2.32.5",
|
||||
"rich>=14.2.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
winnow = "winnow.cli:main"
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/sudolulo/winnow"
|
||||
Changelog = "https://github.com/sudolulo/winnow/blob/main/CHANGELOG.md"
|
||||
|
||||
[tool.uv]
|
||||
conflicts = [
|
||||
[
|
||||
{ package = "onnxruntime" },
|
||||
{ package = "onnxruntime-gpu" },
|
||||
{ package = "onnxruntime-openvino" },
|
||||
],
|
||||
]
|
||||
required-environments = [
|
||||
"sys_platform == 'linux' and platform_machine == 'x86_64'",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"ruff>=0.15.17",
|
||||
]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["winnow"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py313"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I"]
|
||||
|
||||
[tool.deptry]
|
||||
pep621_dev_dependency_groups = ["dev"]
|
||||
|
||||
[tool.deptry.package_module_name_map]
|
||||
pillow = "PIL"
|
||||
opencv-python-headless = "cv2"
|
||||
python-dotenv = "dotenv"
|
||||
insightface = "insightface"
|
||||
numpy = "numpy"
|
||||
onnxruntime-openvino = "onnxruntime"
|
||||
requests = "requests"
|
||||
rich = "rich"
|
||||
|
||||
[tool.deptry.per_rule_ignores]
|
||||
DEP002 = ["onnxruntime-openvino"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
@@ -1,85 +0,0 @@
|
||||
[project]
|
||||
name = "winnow"
|
||||
version = "0.4.11"
|
||||
description = "Selects diverse, high-quality photos from Immich as training data for Frigate face recognition."
|
||||
license = "AGPL-3.0-or-later"
|
||||
requires-python = ">=3.13"
|
||||
authors = [{ name = "Holden Salomon", email = "holden@arch.fyi" }]
|
||||
keywords = ["immich", "frigate", "face-recognition", "training-data", "arcface", "insightface"]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: Scientific/Engineering :: Image Recognition",
|
||||
]
|
||||
dependencies = [
|
||||
"croniter>=5.0.2",
|
||||
"insightface>=0.7.3",
|
||||
"numpy>=2.2.6",
|
||||
"onnxruntime-rocm>=1.16.0",
|
||||
"opencv-python-headless>=4.12.0.88",
|
||||
"pillow>=12.1.0",
|
||||
"python-dotenv>=1.2.1",
|
||||
"requests>=2.32.5",
|
||||
"rich>=14.2.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
winnow = "winnow.cli:main"
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/sudolulo/winnow"
|
||||
Changelog = "https://github.com/sudolulo/winnow/blob/main/CHANGELOG.md"
|
||||
|
||||
[tool.uv]
|
||||
index-strategy = "unsafe-best-match"
|
||||
conflicts = [
|
||||
[
|
||||
{ package = "onnxruntime" },
|
||||
{ package = "onnxruntime-gpu" },
|
||||
{ package = "onnxruntime-rocm" },
|
||||
],
|
||||
]
|
||||
required-environments = [
|
||||
"sys_platform == 'linux' and platform_machine == 'x86_64'",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"ruff>=0.15.17",
|
||||
]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["winnow"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py313"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I"]
|
||||
|
||||
[tool.deptry]
|
||||
pep621_dev_dependency_groups = ["dev"]
|
||||
|
||||
[tool.deptry.package_module_name_map]
|
||||
pillow = "PIL"
|
||||
opencv-python-headless = "cv2"
|
||||
python-dotenv = "dotenv"
|
||||
insightface = "insightface"
|
||||
numpy = "numpy"
|
||||
onnxruntime-rocm = "onnxruntime"
|
||||
requests = "requests"
|
||||
rich = "rich"
|
||||
|
||||
[tool.deptry.per_rule_ignores]
|
||||
DEP002 = ["onnxruntime-rocm"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
+18
-8
@@ -17,11 +17,7 @@ classifiers = [
|
||||
dependencies = [
|
||||
"croniter>=5.0.2",
|
||||
"insightface>=0.7.3",
|
||||
"nvidia-cudnn-cu12>=9.0.0",
|
||||
"numpy>=2.2.6",
|
||||
"onnxruntime-gpu>=1.23.2; sys_platform == 'linux' and platform_machine == 'x86_64'",
|
||||
"onnxruntime>=1.23.2; sys_platform == 'linux' and platform_machine != 'x86_64'",
|
||||
"onnxruntime>=1.23.2; sys_platform != 'linux'",
|
||||
"opencv-python-headless>=4.12.0.88",
|
||||
"pillow>=12.1.0",
|
||||
"python-dotenv>=1.2.1",
|
||||
@@ -29,6 +25,15 @@ dependencies = [
|
||||
"rich>=14.2.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
gpu = [
|
||||
"onnxruntime-gpu>=1.23.2; sys_platform == 'linux' and platform_machine == 'x86_64'",
|
||||
"nvidia-cudnn-cu12>=9.0.0; sys_platform == 'linux' and platform_machine == 'x86_64'",
|
||||
]
|
||||
rocm = ["onnxruntime-rocm>=1.16.0; sys_platform == 'linux' and platform_machine == 'x86_64'"]
|
||||
intel = ["onnxruntime-openvino>=1.20.0; sys_platform == 'linux' and platform_machine == 'x86_64'"]
|
||||
cpu = ["onnxruntime>=1.23.2"]
|
||||
|
||||
[project.scripts]
|
||||
winnow = "winnow.cli:main"
|
||||
|
||||
@@ -38,10 +43,13 @@ Changelog = "https://github.com/sudolulo/winnow/blob/main/CHANGELOG.md"
|
||||
Documentation = "https://github.com/sudolulo/winnow/wiki"
|
||||
|
||||
[tool.uv]
|
||||
index-strategy = "unsafe-best-match"
|
||||
conflicts = [
|
||||
[
|
||||
{ package = "onnxruntime" },
|
||||
{ package = "onnxruntime-gpu" },
|
||||
{ extra = "gpu" },
|
||||
{ extra = "rocm" },
|
||||
{ extra = "intel" },
|
||||
{ extra = "cpu" },
|
||||
],
|
||||
]
|
||||
required-environments = [
|
||||
@@ -77,11 +85,14 @@ insightface = "insightface"
|
||||
numpy = "numpy"
|
||||
nvidia-cudnn-cu12 = "nvidia.cudnn"
|
||||
onnxruntime-gpu = "onnxruntime"
|
||||
onnxruntime-rocm = "onnxruntime"
|
||||
onnxruntime-openvino = "onnxruntime"
|
||||
onnxruntime = "onnxruntime"
|
||||
requests = "requests"
|
||||
rich = "rich"
|
||||
|
||||
[tool.deptry.per_rule_ignores]
|
||||
DEP002 = ["onnxruntime-gpu", "nvidia-cudnn-cu12"]
|
||||
DEP002 = ["onnxruntime-gpu", "nvidia-cudnn-cu12", "onnxruntime-rocm", "onnxruntime-openvino", "onnxruntime"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
@@ -89,4 +100,3 @@ testpaths = ["tests"]
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
|
||||
+3
-1
@@ -30,6 +30,7 @@ def check_models() -> None:
|
||||
NOW = time.time()
|
||||
cron = croniter(SCHEDULE, NOW)
|
||||
next_run = cron.get_next(float)
|
||||
print(f"Next run: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(next_run))}", flush=True)
|
||||
|
||||
while True:
|
||||
now = time.time()
|
||||
@@ -42,7 +43,8 @@ while True:
|
||||
except KeyboardInterrupt:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"winnow run failed: {e}", exc_info=True)
|
||||
logger.error("winnow run failed: %s", e, exc_info=True)
|
||||
print(f"winnow run failed: {e}", flush=True)
|
||||
next_run = cron.get_next(float)
|
||||
print(f"Next run: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(next_run))}", flush=True)
|
||||
time.sleep(max(1, next_run - time.time()))
|
||||
|
||||
@@ -10,7 +10,19 @@ def isolated_cache(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("CACHE_DIR", str(tmp_path))
|
||||
from winnow.config import _Config
|
||||
_Config.reset()
|
||||
# Also reset the SQLite connection so the next call opens the new path
|
||||
import winnow.upload_tracker as ut
|
||||
ut._conn = None
|
||||
ut._conn_path = None
|
||||
yield tmp_path
|
||||
# Teardown
|
||||
if ut._conn is not None:
|
||||
try:
|
||||
ut._conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
ut._conn = None
|
||||
ut._conn_path = None
|
||||
_Config.reset()
|
||||
|
||||
|
||||
@@ -73,12 +85,12 @@ def test_duplicate_marks_are_idempotent():
|
||||
|
||||
# ── frigate_files mapping ─────────────────────────────────────────────────────
|
||||
|
||||
def test_record_and_remove_frigate_file():
|
||||
from winnow.upload_tracker import get_person_summary, record_frigate_file, remove_frigate_file
|
||||
record_frigate_file("Alice", "Alice-1000.webp", "asset-a1")
|
||||
def test_record_and_remove_frigate_files_batch():
|
||||
from winnow.upload_tracker import get_person_summary, record_frigate_files_batch, remove_frigate_file
|
||||
record_frigate_files_batch("Alice", {"Alice-1000.webp": "asset-a1"})
|
||||
assert "Alice-1000.webp" in get_person_summary()["Alice"]["frigate_files"]
|
||||
remove_frigate_file("Alice", "Alice-1000.webp")
|
||||
assert "Alice-1000.webp" not in get_person_summary()["Alice"]["frigate_files"]
|
||||
assert "Alice-1000.webp" not in get_person_summary().get("Alice", {}).get("frigate_files", {})
|
||||
|
||||
|
||||
def test_remove_nonexistent_frigate_file_is_safe():
|
||||
@@ -92,11 +104,11 @@ def test_remove_frigate_file_does_not_unmark_asset():
|
||||
from winnow.upload_tracker import (
|
||||
filter_already_uploaded,
|
||||
mark_uploaded,
|
||||
record_frigate_file,
|
||||
record_frigate_files_batch,
|
||||
remove_frigate_file,
|
||||
)
|
||||
mark_uploaded("asset-a1", person_name="Alice")
|
||||
record_frigate_file("Alice", "Alice-1000.webp", "asset-a1")
|
||||
record_frigate_files_batch("Alice", {"Alice-1000.webp": "asset-a1"})
|
||||
remove_frigate_file("Alice", "Alice-1000.webp")
|
||||
# Asset must still be excluded — it was deliberately replaced, not lost
|
||||
assert filter_already_uploaded(["asset-a1"]) == []
|
||||
@@ -108,14 +120,14 @@ def test_get_tracked_frigate_file_count_zero_when_empty():
|
||||
|
||||
|
||||
def test_get_tracked_frigate_file_count_counts_only_mapped():
|
||||
"""Only files explicitly recorded via record_frigate_file count toward the cap."""
|
||||
from winnow.upload_tracker import get_tracked_frigate_file_count, mark_uploaded, record_frigate_file
|
||||
"""Only files explicitly recorded via record_frigate_files_batch count toward the cap."""
|
||||
from winnow.upload_tracker import get_tracked_frigate_file_count, mark_uploaded, record_frigate_files_batch
|
||||
mark_uploaded("asset-a", person_name="Alice")
|
||||
mark_uploaded("asset-b", person_name="Alice")
|
||||
record_frigate_file("Alice", "Alice-1000.webp", "asset-a")
|
||||
record_frigate_files_batch("Alice", {"Alice-1000.webp": "asset-a"})
|
||||
# asset-b is uploaded but not yet mapped — does not count
|
||||
assert get_tracked_frigate_file_count("Alice") == 1
|
||||
record_frigate_file("Alice", "Alice-1001.webp", "asset-b")
|
||||
record_frigate_files_batch("Alice", {"Alice-1001.webp": "asset-b"})
|
||||
assert get_tracked_frigate_file_count("Alice") == 2
|
||||
|
||||
|
||||
@@ -128,12 +140,11 @@ def test_get_lowest_quality_mapped_file_returns_lowest():
|
||||
from winnow.upload_tracker import (
|
||||
get_lowest_quality_mapped_file,
|
||||
mark_uploaded,
|
||||
record_frigate_file,
|
||||
record_frigate_files_batch,
|
||||
)
|
||||
mark_uploaded("asset-hi", person_name="Alice", score=0.95)
|
||||
mark_uploaded("asset-lo", person_name="Alice", score=0.71)
|
||||
record_frigate_file("Alice", "Alice-1000.webp", "asset-hi")
|
||||
record_frigate_file("Alice", "Alice-1001.webp", "asset-lo")
|
||||
record_frigate_files_batch("Alice", {"Alice-1000.webp": "asset-hi", "Alice-1001.webp": "asset-lo"})
|
||||
result = get_lowest_quality_mapped_file("Alice")
|
||||
assert result is not None
|
||||
frigate_filename, asset_id, score = result
|
||||
@@ -147,12 +158,11 @@ def test_get_lowest_quality_mapped_file_skips_unscored():
|
||||
from winnow.upload_tracker import (
|
||||
get_lowest_quality_mapped_file,
|
||||
mark_uploaded,
|
||||
record_frigate_file,
|
||||
record_frigate_files_batch,
|
||||
)
|
||||
mark_uploaded("asset-scored", person_name="Alice", score=0.85)
|
||||
mark_uploaded("asset-noscr", person_name="Alice")
|
||||
record_frigate_file("Alice", "Alice-1000.webp", "asset-scored")
|
||||
record_frigate_file("Alice", "Alice-1001.webp", "asset-noscr")
|
||||
record_frigate_files_batch("Alice", {"Alice-1000.webp": "asset-scored", "Alice-1001.webp": "asset-noscr"})
|
||||
result = get_lowest_quality_mapped_file("Alice")
|
||||
assert result is not None
|
||||
assert result[1] == "asset-scored" # only scored file is a candidate
|
||||
@@ -166,28 +176,26 @@ def test_get_tracked_frigate_filenames_empty():
|
||||
|
||||
|
||||
def test_get_tracked_frigate_filenames_returns_mapped():
|
||||
from winnow.upload_tracker import get_tracked_frigate_filenames, record_frigate_file
|
||||
record_frigate_file("Alice", "Alice-1000.webp", "asset-a")
|
||||
record_frigate_file("Alice", "Alice-1001.webp", "asset-b")
|
||||
from winnow.upload_tracker import get_tracked_frigate_filenames, record_frigate_files_batch
|
||||
record_frigate_files_batch("Alice", {"Alice-1000.webp": "asset-a", "Alice-1001.webp": "asset-b"})
|
||||
assert get_tracked_frigate_filenames("Alice") == {"Alice-1000.webp", "Alice-1001.webp"}
|
||||
|
||||
|
||||
def test_get_tracked_frigate_filenames_excludes_removed():
|
||||
from winnow.upload_tracker import (
|
||||
get_tracked_frigate_filenames,
|
||||
record_frigate_file,
|
||||
record_frigate_files_batch,
|
||||
remove_frigate_file,
|
||||
)
|
||||
record_frigate_file("Alice", "Alice-1000.webp", "asset-a")
|
||||
record_frigate_file("Alice", "Alice-1001.webp", "asset-b")
|
||||
record_frigate_files_batch("Alice", {"Alice-1000.webp": "asset-a", "Alice-1001.webp": "asset-b"})
|
||||
remove_frigate_file("Alice", "Alice-1000.webp")
|
||||
assert get_tracked_frigate_filenames("Alice") == {"Alice-1001.webp"}
|
||||
|
||||
|
||||
def test_get_tracked_frigate_filenames_isolated_by_person():
|
||||
from winnow.upload_tracker import get_tracked_frigate_filenames, record_frigate_file
|
||||
record_frigate_file("Alice", "Alice-1000.webp", "asset-a")
|
||||
record_frigate_file("Bob", "Bob-2000.webp", "asset-b")
|
||||
from winnow.upload_tracker import get_tracked_frigate_filenames, record_frigate_files_batch
|
||||
record_frigate_files_batch("Alice", {"Alice-1000.webp": "asset-a"})
|
||||
record_frigate_files_batch("Bob", {"Bob-2000.webp": "asset-b"})
|
||||
assert get_tracked_frigate_filenames("Alice") == {"Alice-1000.webp"}
|
||||
assert get_tracked_frigate_filenames("Bob") == {"Bob-2000.webp"}
|
||||
|
||||
@@ -198,12 +206,11 @@ def test_get_lowest_quality_exclude_skips_specified_file():
|
||||
from winnow.upload_tracker import (
|
||||
get_lowest_quality_mapped_file,
|
||||
mark_uploaded,
|
||||
record_frigate_file,
|
||||
record_frigate_files_batch,
|
||||
)
|
||||
mark_uploaded("asset-lo", person_name="Alice", score=0.10)
|
||||
mark_uploaded("asset-hi", person_name="Alice", score=0.90)
|
||||
record_frigate_file("Alice", "Alice-lo.webp", "asset-lo")
|
||||
record_frigate_file("Alice", "Alice-hi.webp", "asset-hi")
|
||||
record_frigate_files_batch("Alice", {"Alice-lo.webp": "asset-lo", "Alice-hi.webp": "asset-hi"})
|
||||
result = get_lowest_quality_mapped_file("Alice", exclude={"Alice-lo.webp"})
|
||||
assert result is not None
|
||||
assert result[1] == "asset-hi" # lo was excluded; hi is returned
|
||||
@@ -213,29 +220,28 @@ def test_get_lowest_quality_exclude_all_returns_none():
|
||||
from winnow.upload_tracker import (
|
||||
get_lowest_quality_mapped_file,
|
||||
mark_uploaded,
|
||||
record_frigate_file,
|
||||
record_frigate_files_batch,
|
||||
)
|
||||
mark_uploaded("asset-a", person_name="Alice", score=0.50)
|
||||
record_frigate_file("Alice", "Alice-a.webp", "asset-a")
|
||||
record_frigate_files_batch("Alice", {"Alice-a.webp": "asset-a"})
|
||||
assert get_lowest_quality_mapped_file("Alice", exclude={"Alice-a.webp"}) is None
|
||||
|
||||
|
||||
# ── get_most_redundant_mapped_file ────────────────────────────────────────────
|
||||
|
||||
def test_get_most_redundant_none_when_no_frigate_scores():
|
||||
from winnow.upload_tracker import get_most_redundant_mapped_file, mark_uploaded, record_frigate_file
|
||||
from winnow.upload_tracker import get_most_redundant_mapped_file, mark_uploaded, record_frigate_files_batch
|
||||
mark_uploaded("asset-a", person_name="Alice", score=0.80)
|
||||
record_frigate_file("Alice", "Alice-a.webp", "asset-a")
|
||||
record_frigate_files_batch("Alice", {"Alice-a.webp": "asset-a"})
|
||||
# blur score only, no frigate_score → no candidates
|
||||
assert get_most_redundant_mapped_file("Alice") is None
|
||||
|
||||
|
||||
def test_get_most_redundant_returns_highest_frigate_score():
|
||||
from winnow.upload_tracker import get_most_redundant_mapped_file, mark_uploaded, record_frigate_file
|
||||
from winnow.upload_tracker import get_most_redundant_mapped_file, mark_uploaded, record_frigate_files_batch
|
||||
mark_uploaded("asset-novel", person_name="Alice", score=0.50, frigate_score=0.31)
|
||||
mark_uploaded("asset-redundant", person_name="Alice", score=0.90, frigate_score=0.88)
|
||||
record_frigate_file("Alice", "Alice-novel.webp", "asset-novel")
|
||||
record_frigate_file("Alice", "Alice-redundant.webp", "asset-redundant")
|
||||
record_frigate_files_batch("Alice", {"Alice-novel.webp": "asset-novel", "Alice-redundant.webp": "asset-redundant"})
|
||||
result = get_most_redundant_mapped_file("Alice")
|
||||
assert result is not None
|
||||
frigate_filename, asset_id, score = result
|
||||
@@ -245,18 +251,17 @@ def test_get_most_redundant_returns_highest_frigate_score():
|
||||
|
||||
|
||||
def test_get_most_redundant_exclude_skips_file():
|
||||
from winnow.upload_tracker import get_most_redundant_mapped_file, mark_uploaded, record_frigate_file
|
||||
from winnow.upload_tracker import get_most_redundant_mapped_file, mark_uploaded, record_frigate_files_batch
|
||||
mark_uploaded("asset-hi", person_name="Alice", score=0.9, frigate_score=0.85)
|
||||
mark_uploaded("asset-lo", person_name="Alice", score=0.5, frigate_score=0.40)
|
||||
record_frigate_file("Alice", "Alice-hi.webp", "asset-hi")
|
||||
record_frigate_file("Alice", "Alice-lo.webp", "asset-lo")
|
||||
record_frigate_files_batch("Alice", {"Alice-hi.webp": "asset-hi", "Alice-lo.webp": "asset-lo"})
|
||||
result = get_most_redundant_mapped_file("Alice", exclude={"Alice-hi.webp"})
|
||||
assert result is not None
|
||||
assert result[1] == "asset-lo" # hi excluded; lo is next highest
|
||||
|
||||
|
||||
def test_get_most_redundant_exclude_all_returns_none():
|
||||
from winnow.upload_tracker import get_most_redundant_mapped_file, mark_uploaded, record_frigate_file
|
||||
from winnow.upload_tracker import get_most_redundant_mapped_file, mark_uploaded, record_frigate_files_batch
|
||||
mark_uploaded("asset-a", person_name="Alice", score=0.5, frigate_score=0.70)
|
||||
record_frigate_file("Alice", "Alice-a.webp", "asset-a")
|
||||
record_frigate_files_batch("Alice", {"Alice-a.webp": "asset-a"})
|
||||
assert get_most_redundant_mapped_file("Alice", exclude={"Alice-a.webp"}) is None
|
||||
|
||||
-1884
File diff suppressed because it is too large
Load Diff
-1941
File diff suppressed because it is too large
Load Diff
-1933
File diff suppressed because it is too large
Load Diff
@@ -2,24 +2,18 @@ version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.13"
|
||||
resolution-markers = [
|
||||
"platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"platform_machine != 'aarch64' and platform_machine != 's390x' and platform_machine != 'x86_64' and sys_platform == 'linux'",
|
||||
"platform_machine == 's390x' and sys_platform == 'linux'",
|
||||
"platform_machine != 's390x' and sys_platform == 'win32'",
|
||||
"platform_machine == 's390x' and sys_platform == 'win32'",
|
||||
"platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'",
|
||||
"platform_machine == 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'",
|
||||
"platform_machine != 's390x' and sys_platform == 'darwin'",
|
||||
"platform_machine == 's390x' and sys_platform == 'darwin'",
|
||||
"platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||
"platform_machine != 's390x'",
|
||||
"platform_machine == 's390x'",
|
||||
]
|
||||
required-markers = [
|
||||
"platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||
"platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
]
|
||||
conflicts = [[
|
||||
{ package = "onnxruntime" },
|
||||
{ package = "onnxruntime-gpu" },
|
||||
{ package = "winnow", extra = "cpu" },
|
||||
{ package = "winnow", extra = "gpu" },
|
||||
{ package = "winnow", extra = "intel" },
|
||||
{ package = "winnow", extra = "rocm" },
|
||||
]]
|
||||
|
||||
[[package]]
|
||||
@@ -97,6 +91,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coloredlogs"
|
||||
version = "15.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "humanfriendly" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload-time = "2021-06-11T10:22:45.202Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload-time = "2021-06-11T10:22:42.561Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "croniter"
|
||||
version = "6.2.2"
|
||||
@@ -117,6 +123,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "humanfriendly"
|
||||
version = "10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyreadline3", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.18"
|
||||
@@ -231,6 +249,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/3f/3d42e9a78fe5edf792a83c074b13b9b770092a4fbf3462872f4303135f09/ml_dtypes-0.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:11942cbf2cf92157db91e5022633c0d9474d4dfd813a909383bd23ce828a4b7d", size = 168825, upload-time = "2025-11-17T22:32:23.766Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mpmath"
|
||||
version = "1.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "networkx"
|
||||
version = "3.6.1"
|
||||
@@ -290,36 +317,12 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nvidia-cublas-cu12"
|
||||
version = "12.6.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/af/eb/ff4b8c503fa1f1796679dce648854d58751982426e4e4b37d6fce49d259c/nvidia_cublas_cu12-12.6.4.1-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08ed2686e9875d01b58e3cb379c6896df8e76c75e0d4a7f7dace3d7b6d9ef8eb", size = 393138322, upload-time = "2024-11-20T17:40:25.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/0d/f1f0cadbf69d5b9ef2e4f744c9466cb0a850741d08350736dfdb4aa89569/nvidia_cublas_cu12-12.6.4.1-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:235f728d6e2a409eddf1df58d5b0921cf80cfa9e72b9f2775ccb7b4a87984668", size = 390794615, upload-time = "2024-11-20T17:39:52.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/f7/985e9bdbe3e0ac9298fcc8cfa51a392862a46a0ffaccbbd56939b62a9c83/nvidia_cublas_cu12-12.6.4.1-py3-none-win_amd64.whl", hash = "sha256:9e4fa264f4d8a4eb0cdbd34beadc029f453b3bafae02401e999cf3d5a5af75f8", size = 434535301, upload-time = "2024-11-20T17:50:41.681Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nvidia-cublas-cu12"
|
||||
version = "12.9.2.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"platform_machine != 'aarch64' and platform_machine != 's390x' and platform_machine != 'x86_64' and sys_platform == 'linux'",
|
||||
"platform_machine == 's390x' and sys_platform == 'linux'",
|
||||
"platform_machine != 's390x' and sys_platform == 'win32'",
|
||||
"platform_machine == 's390x' and sys_platform == 'win32'",
|
||||
"platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'",
|
||||
"platform_machine == 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'",
|
||||
"platform_machine != 's390x' and sys_platform == 'darwin'",
|
||||
"platform_machine == 's390x' and sys_platform == 'darwin'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "nvidia-cuda-nvrtc-cu12", marker = "platform_machine != 'x86_64' or sys_platform != 'linux' or (extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" },
|
||||
{ name = "nvidia-cuda-nvrtc-cu12" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/a2/c96163a0fff1839c0c9548bbdeae7b853b867009e33b9b9264adc238b1cf/nvidia_cublas_cu12-12.9.2.10-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:5572131a59c3eebeeb1c4c8144f772d49372c20124916e072a0e3fc30df421d5", size = 575012079, upload-time = "2026-04-08T18:51:47.303Z" },
|
||||
@@ -337,39 +340,12 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/52/de/823919be3b9d0ccbf1f784035423c5f18f4267fb0123558d58b813c6ec86/nvidia_cuda_nvrtc_cu12-12.9.86-py3-none-win_amd64.whl", hash = "sha256:72972ebdcf504d69462d3bcd67e7b81edd25d0fb85a2c46d3ea3517666636349", size = 76408187, upload-time = "2025-06-05T20:12:27.819Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nvidia-cudnn-cu12"
|
||||
version = "9.10.2.21"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "nvidia-cublas-cu12", version = "12.6.4.1", source = { registry = "https://pypi.org/simple" }, marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine != 'x86_64' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform != 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/41/e79269ce215c857c935fd86bcfe91a451a584dfc27f1e068f568b9ad1ab7/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:c9132cc3f8958447b4910a1720036d9eff5928cc3179b0a51fb6d167c6cc87d8", size = 705026878, upload-time = "2025-06-06T21:52:51.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/51/e123d997aa098c61d029f76663dedbfb9bc8dcf8c60cbd6adbe42f76d049/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:949452be657fa16687d0930933f032835951ef0892b37d2d53824d1a84dc97a8", size = 706758467, upload-time = "2025-06-06T21:54:08.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/90/0bd6e586701b3a890fd38aa71c387dab4883d619d6e5ad912ccbd05bfd67/nvidia_cudnn_cu12-9.10.2.21-py3-none-win_amd64.whl", hash = "sha256:c6288de7d63e6cf62988f0923f96dc339cea362decb1bf5b3141883392a7d65e", size = 692992268, upload-time = "2025-06-06T21:55:18.114Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nvidia-cudnn-cu12"
|
||||
version = "9.23.1.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"platform_machine != 'aarch64' and platform_machine != 's390x' and platform_machine != 'x86_64' and sys_platform == 'linux'",
|
||||
"platform_machine == 's390x' and sys_platform == 'linux'",
|
||||
"platform_machine != 's390x' and sys_platform == 'win32'",
|
||||
"platform_machine == 's390x' and sys_platform == 'win32'",
|
||||
"platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'",
|
||||
"platform_machine == 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'",
|
||||
"platform_machine != 's390x' and sys_platform == 'darwin'",
|
||||
"platform_machine == 's390x' and sys_platform == 'darwin'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "nvidia-cublas-cu12", version = "12.9.2.10", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'linux' or (extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" },
|
||||
{ name = "nvidia-cublas-cu12" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/10/30/ecca1c8194c8077c4b57a3d96b56d96f15852551b01b919bae6429d92218/nvidia_cudnn_cu12-9.23.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:6dbc18f05aab2a323a4ffd43d985410608f7db7db9a8596e189cddbd3e527441", size = 778220760, upload-time = "2026-06-09T19:38:19.281Z" },
|
||||
@@ -439,10 +415,10 @@ name = "onnxruntime-gpu"
|
||||
version = "1.26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "flatbuffers", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine != 'x86_64' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform != 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" },
|
||||
{ name = "numpy", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine != 'x86_64' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform != 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" },
|
||||
{ name = "packaging", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine != 'x86_64' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform != 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" },
|
||||
{ name = "protobuf", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine != 'x86_64' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform != 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" },
|
||||
{ name = "flatbuffers" },
|
||||
{ name = "numpy" },
|
||||
{ name = "packaging" },
|
||||
{ name = "protobuf" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/97/fe8979f44b9275654b42f7bb556e30789b71a1b22998c83b540df2b1b774/onnxruntime_gpu-1.26.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cfda2fad535595bfc3e570eb588092717711dcb2957656d814695e0c9ceb1508", size = 276974871, upload-time = "2026-05-08T19:15:58.052Z" },
|
||||
@@ -453,6 +429,38 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/97/91/93ffe5431d154989f5e04864a25a97eea480997d771232bcbbc538188241/onnxruntime_gpu-1.26.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56dc7b73954ff4bdc71f5b8ab306b6f61be5d007881b6ef423a609e2b9cd088b", size = 276991545, upload-time = "2026-05-08T19:16:33.347Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "onnxruntime-openvino"
|
||||
version = "1.24.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "flatbuffers" },
|
||||
{ name = "numpy" },
|
||||
{ name = "packaging" },
|
||||
{ name = "protobuf" },
|
||||
{ name = "sympy" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/08/07/f225999919f56506b603aaa3ff837ad563ab26f86906ed7fa7e5abcd849e/onnxruntime_openvino-1.24.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:2c3bb73e68ac27f4891af8a595c1faf574ec68b772e6583c90a0b997a1822782", size = 84433183, upload-time = "2026-02-26T13:44:50.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/92/46ae2cd565961a89189900f385bb2f13a9fa731ea4674001d23720fbb1e0/onnxruntime_openvino-1.24.1-cp313-cp313-win_amd64.whl", hash = "sha256:434bf49aa71393c577a456c9d76c98e6d6958a833fa0876793e3d5437b5a511a", size = 13658485, upload-time = "2026-02-26T13:44:53.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "onnxruntime-rocm"
|
||||
version = "1.22.2.post1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "coloredlogs" },
|
||||
{ name = "flatbuffers" },
|
||||
{ name = "numpy" },
|
||||
{ name = "packaging" },
|
||||
{ name = "protobuf" },
|
||||
{ name = "sympy" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/33/d0/e6f011c8e01853a8a4a56b48b1421257d89767fa76e8a273eeb2c54eb725/onnxruntime_rocm-1.22.2.post1-cp313-cp313-manylinux_2_35_x86_64.whl", hash = "sha256:19b56e9e41da3c7042dc97223ef46976ed8bbdf0ffe43646129f773647794b44", size = 217937775, upload-time = "2025-09-11T16:40:34.403Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opencv-python"
|
||||
version = "4.13.0.92"
|
||||
@@ -589,12 +597,21 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyreadline3"
|
||||
version = "3.5.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b6/6d/f94028646d7bbe6d9d873c47ee7c246f2d29129d253f0d96cb6fcab70733/pyreadline3-3.5.6.tar.gz", hash = "sha256:61e53218b99656091ddb077df9e71f25850e72e030b6183b39c9b7e6e4f4a9bf", size = 100368, upload-time = "2026-05-14T17:55:04.471Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/5e/35c856e186b74678c24927847ad9895a51f1bc02a0c6126477a6c6040064/pyreadline3-3.5.6-py3-none-any.whl", hash = "sha256:8449b734232e42a5dcd74048e39b60db2839a4c38cf3ae2bf7707d58b5389c0d", size = 85243, upload-time = "2026-05-14T17:55:03.262Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32' or (extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" },
|
||||
{ name = "colorama", marker = "sys_platform == 'win32' or (extra == 'extra-6-winnow-cpu' and extra == 'extra-6-winnow-gpu') or (extra == 'extra-6-winnow-cpu' and extra == 'extra-6-winnow-intel') or (extra == 'extra-6-winnow-cpu' and extra == 'extra-6-winnow-rocm') or (extra == 'extra-6-winnow-gpu' and extra == 'extra-6-winnow-intel') or (extra == 'extra-6-winnow-gpu' and extra == 'extra-6-winnow-rocm') or (extra == 'extra-6-winnow-intel' and extra == 'extra-6-winnow-rocm')" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
@@ -789,6 +806,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sympy"
|
||||
version = "1.14.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mpmath" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tifffile"
|
||||
version = "2026.6.1"
|
||||
@@ -806,7 +835,7 @@ name = "tqdm"
|
||||
version = "4.68.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32' or (extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" },
|
||||
{ name = "colorama", marker = "sys_platform == 'win32' or (extra == 'extra-6-winnow-cpu' and extra == 'extra-6-winnow-gpu') or (extra == 'extra-6-winnow-cpu' and extra == 'extra-6-winnow-intel') or (extra == 'extra-6-winnow-cpu' and extra == 'extra-6-winnow-rocm') or (extra == 'extra-6-winnow-gpu' and extra == 'extra-6-winnow-intel') or (extra == 'extra-6-winnow-gpu' and extra == 'extra-6-winnow-rocm') or (extra == 'extra-6-winnow-intel' and extra == 'extra-6-winnow-rocm')" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/85/05/0d5260f1f1ca784f4a4a0def9cbe6affe587f5b4025328d446c3d67765f4/tqdm-4.68.2.tar.gz", hash = "sha256:89c230e8dbc67c7615c142487111222f878c77427ea09549960f62389e258add", size = 171923, upload-time = "2026-06-09T13:26:42.539Z" }
|
||||
wheels = [
|
||||
@@ -839,10 +868,6 @@ dependencies = [
|
||||
{ name = "croniter" },
|
||||
{ name = "insightface" },
|
||||
{ name = "numpy" },
|
||||
{ name = "nvidia-cudnn-cu12", version = "9.10.2.21", source = { registry = "https://pypi.org/simple" }, marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine != 'x86_64' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform != 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" },
|
||||
{ name = "nvidia-cudnn-cu12", version = "9.23.1.3", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'linux' or (extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" },
|
||||
{ name = "onnxruntime", marker = "platform_machine != 'x86_64' or sys_platform != 'linux' or (extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" },
|
||||
{ name = "onnxruntime-gpu", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine != 'x86_64' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform != 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" },
|
||||
{ name = "opencv-python-headless" },
|
||||
{ name = "pillow" },
|
||||
{ name = "python-dotenv" },
|
||||
@@ -850,6 +875,21 @@ dependencies = [
|
||||
{ name = "rich" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
cpu = [
|
||||
{ name = "onnxruntime" },
|
||||
]
|
||||
gpu = [
|
||||
{ name = "nvidia-cudnn-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||
{ name = "onnxruntime-gpu", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||
]
|
||||
intel = [
|
||||
{ name = "onnxruntime-openvino", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||
]
|
||||
rocm = [
|
||||
{ name = "onnxruntime-rocm", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
@@ -861,16 +901,18 @@ requires-dist = [
|
||||
{ name = "croniter", specifier = ">=5.0.2" },
|
||||
{ name = "insightface", specifier = ">=0.7.3" },
|
||||
{ name = "numpy", specifier = ">=2.2.6" },
|
||||
{ name = "nvidia-cudnn-cu12", specifier = ">=9.0.0" },
|
||||
{ name = "onnxruntime", marker = "sys_platform != 'linux'", specifier = ">=1.23.2" },
|
||||
{ name = "onnxruntime", marker = "platform_machine != 'x86_64' and sys_platform == 'linux'", specifier = ">=1.23.2" },
|
||||
{ name = "onnxruntime-gpu", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = ">=1.23.2" },
|
||||
{ name = "nvidia-cudnn-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'gpu'", specifier = ">=9.0.0" },
|
||||
{ name = "onnxruntime", marker = "extra == 'cpu'", specifier = ">=1.23.2" },
|
||||
{ name = "onnxruntime-gpu", marker = "platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'gpu'", specifier = ">=1.23.2" },
|
||||
{ name = "onnxruntime-openvino", marker = "platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'intel'", specifier = ">=1.20.0" },
|
||||
{ name = "onnxruntime-rocm", marker = "platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'rocm'", specifier = ">=1.16.0" },
|
||||
{ name = "opencv-python-headless", specifier = ">=4.12.0.88" },
|
||||
{ name = "pillow", specifier = ">=12.1.0" },
|
||||
{ name = "python-dotenv", specifier = ">=1.2.1" },
|
||||
{ name = "requests", specifier = ">=2.32.5" },
|
||||
{ name = "rich", specifier = ">=14.2.0" },
|
||||
]
|
||||
provides-extras = ["gpu", "rocm", "intel", "cpu"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
|
||||
+2
-2
@@ -85,7 +85,7 @@ class EmbeddingCache:
|
||||
try:
|
||||
np.save(self._path(asset_id, model), embedding)
|
||||
except Exception as e:
|
||||
logger.debug(f"Cache write failed for {asset_id}: {e}")
|
||||
logger.debug("Cache write failed for %s: %s", asset_id, e)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Delete all cached embeddings."""
|
||||
@@ -96,7 +96,7 @@ class EmbeddingCache:
|
||||
if f.endswith(".npy"):
|
||||
os.remove(os.path.join(self.cache_dir, f))
|
||||
count += 1
|
||||
logger.info(f"Cleared {count} cached embeddings.")
|
||||
logger.info("Cleared %s cached embeddings.", count)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@ import sys
|
||||
from rich import print as rprint
|
||||
from rich.prompt import Confirm
|
||||
|
||||
from .config import Config, ConfigManager
|
||||
from .config import Config
|
||||
from .executor import execute_jobs, upload_to_frigate
|
||||
from .immich_api import get_immich_version, get_people, merge_people
|
||||
from .jobs import _show_preview, auto_configure, interactive_configure
|
||||
@@ -168,7 +168,7 @@ def main() -> None:
|
||||
"Image quality issues caused by non-default values will not be investigated.[/dim]\n"
|
||||
)
|
||||
|
||||
ConfigManager.get().interactive_setup()
|
||||
Config.interactive_setup()
|
||||
|
||||
try:
|
||||
Config.validate()
|
||||
|
||||
+61
-73
@@ -9,51 +9,77 @@ from typing import ClassVar
|
||||
from dotenv import load_dotenv
|
||||
from rich.prompt import Prompt
|
||||
|
||||
load_dotenv()
|
||||
|
||||
CONFIG_FILE = Path(".immich_config.json")
|
||||
|
||||
|
||||
class _Config:
|
||||
"""Singleton configuration with uppercase attribute access for backward compatibility."""
|
||||
"""Singleton configuration with lazy loading via __getattr__.
|
||||
|
||||
Class-level attributes are annotations only (no defaults), so attribute
|
||||
access on an un-loaded instance falls through to __getattr__, which
|
||||
triggers _load() exactly once.
|
||||
"""
|
||||
|
||||
_instance: ClassVar["_Config | None"] = None
|
||||
|
||||
# Configuration values
|
||||
IMMICH_URL: str | None = None
|
||||
API_KEY: str | None = None
|
||||
OUTPUT_DIR: str = "./frigate_train"
|
||||
YEARS_FILTER: int = 10
|
||||
# Annotations only — no class-level defaults so __getattr__ fires on first access
|
||||
IMMICH_URL: str | None
|
||||
API_KEY: str | None
|
||||
OUTPUT_DIR: str
|
||||
YEARS_FILTER: int
|
||||
|
||||
# Quality filtering
|
||||
MIN_FACE_WIDTH: int = 90
|
||||
BLUR_THRESHOLD: float = 120.0
|
||||
MIN_CONFIDENCE: float = 0.7
|
||||
MAX_AUTO_IMAGES: int = 20
|
||||
QUALITY_REPLACEMENT: bool = True
|
||||
FRIGATE_SCORE_CEILING: float | None = None
|
||||
ENABLE_FRIGATE_SCORES: bool = True
|
||||
MIN_FACE_WIDTH: int
|
||||
BLUR_THRESHOLD: float
|
||||
MIN_CONFIDENCE: float
|
||||
MAX_AUTO_IMAGES: int
|
||||
QUALITY_REPLACEMENT: bool
|
||||
FRIGATE_SCORE_CEILING: float | None
|
||||
ENABLE_FRIGATE_SCORES: bool
|
||||
|
||||
# People filtering
|
||||
MIN_FACE_COUNT: int = 3
|
||||
MERGE_DUPLICATE_PEOPLE: bool = False
|
||||
MIN_FACE_COUNT: int
|
||||
MERGE_DUPLICATE_PEOPLE: bool
|
||||
|
||||
# Output quality
|
||||
FACE_MARGIN: float = 0.15
|
||||
USE_FULL_RESOLUTION: bool = True
|
||||
ENABLE_FACE_ALIGNMENT: bool = True
|
||||
FACE_MARGIN: float
|
||||
USE_FULL_RESOLUTION: bool
|
||||
ENABLE_FACE_ALIGNMENT: bool
|
||||
|
||||
ENABLE_CACHE: bool = True
|
||||
CACHE_DIR: str = ".if_cache"
|
||||
ENABLE_CACHE: bool
|
||||
CACHE_DIR: str
|
||||
|
||||
def __new__(cls) -> "_Config":
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._load()
|
||||
# Do NOT call _load() here — keep __new__ I/O-free so that import
|
||||
# time does not trigger env/file reads.
|
||||
return cls._instance
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
"""Called only when the attribute is not found on the instance.
|
||||
|
||||
On first access to any config attribute, load all values from env/file
|
||||
and return the requested one. Re-registers self as _instance so that
|
||||
a subsequent reset() correctly finds and clears this object's attrs.
|
||||
"""
|
||||
if name.startswith("_"):
|
||||
raise AttributeError(name)
|
||||
self._load()
|
||||
# Re-register self as the singleton so reset() can clear our __dict__.
|
||||
# This handles the case where __getattr__ is called on the module-level
|
||||
# Config object after a reset() set _instance to None.
|
||||
_Config._instance = self
|
||||
# _load() sets the attribute as an instance attr; retrieve it directly
|
||||
# to avoid infinite recursion through __getattr__.
|
||||
try:
|
||||
return self.__dict__[name]
|
||||
except KeyError:
|
||||
raise AttributeError(f"_Config has no attribute {name!r}")
|
||||
|
||||
def _load(self) -> None:
|
||||
"""Load configuration from environment and config file."""
|
||||
load_dotenv()
|
||||
# Load from environment (highest priority)
|
||||
self.IMMICH_URL = os.getenv("IMMICH_URL")
|
||||
self.API_KEY = os.getenv("API_KEY")
|
||||
@@ -75,19 +101,23 @@ class _Config:
|
||||
self.ENABLE_CACHE = os.getenv("ENABLE_CACHE", "true").lower() in ("true", "1", "yes")
|
||||
self.CACHE_DIR = os.getenv("CACHE_DIR", ".if_cache")
|
||||
|
||||
# Fall back to config file for non-sensitive values (API_KEY not stored here)
|
||||
# Fall back to config file only when the env var is genuinely absent (None).
|
||||
# An explicitly empty env var (IMMICH_URL="") takes priority over the file.
|
||||
if CONFIG_FILE.exists():
|
||||
try:
|
||||
data = json.loads(CONFIG_FILE.read_text())
|
||||
self.IMMICH_URL = self.IMMICH_URL or data.get("IMMICH_URL")
|
||||
if not os.getenv("OUTPUT_DIR"):
|
||||
if self.IMMICH_URL is None:
|
||||
self.IMMICH_URL = data.get("IMMICH_URL")
|
||||
if os.getenv("OUTPUT_DIR") is None:
|
||||
self.OUTPUT_DIR = data.get("OUTPUT_DIR", self.OUTPUT_DIR)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logging.warning(f"Failed to load config file: {e}")
|
||||
logging.warning("Failed to load config file: %s", e)
|
||||
|
||||
@classmethod
|
||||
def reset(cls) -> None:
|
||||
"""Reset the singleton — mainly useful for testing or delayed env setup."""
|
||||
if cls._instance is not None:
|
||||
cls._instance.__dict__.clear()
|
||||
cls._instance = None
|
||||
|
||||
def save(self) -> None:
|
||||
@@ -106,9 +136,9 @@ class _Config:
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
logging.info(f"Configuration saved to {CONFIG_FILE}")
|
||||
logging.info("Configuration saved to %s", CONFIG_FILE)
|
||||
except OSError as e:
|
||||
logging.error(f"Failed to save config: {e}")
|
||||
logging.error("Failed to save config: %s", e)
|
||||
|
||||
def interactive_setup(self) -> None:
|
||||
"""Prompt user for missing configuration."""
|
||||
@@ -132,52 +162,10 @@ class _Config:
|
||||
raise ValueError("Missing Immich URL or API Key.")
|
||||
|
||||
|
||||
# Singleton instance — use a lazy property pattern to avoid import-time side effects
|
||||
# when env vars aren't yet set. Call Config.instance() or just access attributes on
|
||||
# the module-level `Config` (which delegates to the singleton).
|
||||
class _ConfigAccessor:
|
||||
"""Lazy accessor that defers singleton creation until first attribute access.
|
||||
|
||||
This avoids reading .env and config files at import time, so environment
|
||||
variables set after importing the module are properly picked up.
|
||||
"""
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
return getattr(_Config(), name)
|
||||
|
||||
def __setattr__(self, name: str, value):
|
||||
if name.startswith("_"):
|
||||
super().__setattr__(name, value)
|
||||
else:
|
||||
setattr(_Config(), name, value)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset the underlying singleton."""
|
||||
_Config.reset()
|
||||
|
||||
def interactive_setup(self) -> None:
|
||||
"""Delegate to the singleton."""
|
||||
_Config().interactive_setup()
|
||||
|
||||
def validate(self) -> None:
|
||||
"""Delegate to the singleton."""
|
||||
_Config().validate()
|
||||
|
||||
def save(self) -> None:
|
||||
"""Delegate to the singleton."""
|
||||
_Config().save()
|
||||
|
||||
|
||||
Config = _ConfigAccessor()
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
@staticmethod
|
||||
def get() -> _Config:
|
||||
return _Config()
|
||||
# Module-level singleton — lazy: no I/O until first attribute access.
|
||||
Config = _Config()
|
||||
|
||||
|
||||
def get_headers() -> dict[str, str]:
|
||||
"""Return HTTP headers for Immich API requests."""
|
||||
return {"x-api-key": Config.API_KEY or "", "Accept": "application/json"}
|
||||
|
||||
|
||||
+20
-16
@@ -10,6 +10,7 @@ Selection pipeline:
|
||||
"""
|
||||
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from io import BytesIO
|
||||
|
||||
import numpy as np
|
||||
@@ -30,6 +31,7 @@ def select_diverse_assets(
|
||||
selection_mode: str = "smart",
|
||||
person_id: str | None = None,
|
||||
progress_callback=None,
|
||||
fetch_fn=None,
|
||||
) -> list:
|
||||
"""
|
||||
Select diverse assets using cluster-aware FPS or time spread.
|
||||
@@ -40,6 +42,8 @@ def select_diverse_assets(
|
||||
entity_name: Name of the person for logging
|
||||
selection_mode: 'smart' (embedding-based) or 'time' (time spread)
|
||||
progress_callback: Optional callback(current, total) for progress
|
||||
fetch_fn: Optional callable(asset_id) -> Image | None; defaults to
|
||||
_fetch_thumbnail. Injected for testability.
|
||||
|
||||
Returns:
|
||||
List of selected assets
|
||||
@@ -57,9 +61,9 @@ def select_diverse_assets(
|
||||
return _select_time_spread(assets, limit)
|
||||
|
||||
try:
|
||||
return _select_by_embedding(assets, limit, person_id, progress_callback)
|
||||
return _select_by_embedding(assets, limit, person_id, progress_callback, fetch_fn=fetch_fn)
|
||||
except Exception as e:
|
||||
logger.error(f"Smart Diversity failed: {e}. Falling back to time spread.")
|
||||
logger.error("Smart Diversity failed: %s. Falling back to time spread.", e)
|
||||
return _select_time_spread(assets, limit)
|
||||
|
||||
|
||||
@@ -178,6 +182,7 @@ def _select_by_embedding(
|
||||
limit: int | str,
|
||||
person_id: str | None = None,
|
||||
progress_callback=None,
|
||||
fetch_fn=None,
|
||||
) -> list:
|
||||
"""Select assets using embedding-based cluster-aware FPS.
|
||||
|
||||
@@ -210,8 +215,7 @@ def _select_by_embedding(
|
||||
# representative in practice, but heavy JPEG compression on a preview could
|
||||
# produce a subtly different embedding than the full-res version. For most
|
||||
# libraries this is negligible; it matters if Immich preview quality is low.
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
_fetch = fetch_fn or _fetch_thumbnail
|
||||
_BATCH = 32
|
||||
embeddings, valid_candidates, confidence_scores = [], [], []
|
||||
quality_filtered = 0
|
||||
@@ -223,7 +227,7 @@ def _select_by_embedding(
|
||||
# Download this batch concurrently
|
||||
batch_images: dict[str, Image.Image] = {}
|
||||
with ThreadPoolExecutor(max_workers=min(8, len(batch))) as pool:
|
||||
futures = {pool.submit(_fetch_thumbnail, a["id"]): a for a in batch}
|
||||
futures = {pool.submit(_fetch, a["id"]): a for a in batch}
|
||||
for future in as_completed(futures):
|
||||
asset = futures[future]
|
||||
try:
|
||||
@@ -231,7 +235,7 @@ def _select_by_embedding(
|
||||
if img is not None:
|
||||
batch_images[asset["id"]] = img
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to fetch thumbnail for {asset['id']}: {e}")
|
||||
logger.debug("Failed to fetch thumbnail for %s: %s", asset["id"], e)
|
||||
continue
|
||||
|
||||
# Process each image; batch_images goes out of scope after this loop,
|
||||
@@ -257,7 +261,7 @@ def _select_by_embedding(
|
||||
)
|
||||
if not quality.passed:
|
||||
quality_filtered += 1
|
||||
logger.debug(f"Quality filtered {asset['id']}: {quality.reason}")
|
||||
logger.debug("Quality filtered %s: %s", asset["id"], quality.reason)
|
||||
continue
|
||||
|
||||
asset["quality_score"] = quality.blur_score
|
||||
@@ -271,14 +275,14 @@ def _select_by_embedding(
|
||||
confidence_scores.append(confidence)
|
||||
|
||||
if quality_filtered > 0:
|
||||
logger.info(f"Quality filtering removed {quality_filtered} images.")
|
||||
logger.info("Quality filtering removed %s images.", quality_filtered)
|
||||
|
||||
if not embeddings:
|
||||
logger.warning("No valid embeddings found. Falling back to time spread.")
|
||||
return _select_time_spread(assets, limit)
|
||||
|
||||
if limit != "auto" and len(valid_candidates) < limit:
|
||||
logger.warning(f"Only {len(valid_candidates)} valid embeddings. Returning all.")
|
||||
logger.warning("Only %s valid embeddings. Returning all.", len(valid_candidates))
|
||||
return valid_candidates
|
||||
|
||||
# --- Phase 5: Near-duplicate removal ---
|
||||
@@ -292,7 +296,7 @@ def _select_by_embedding(
|
||||
|
||||
# Re-check after dedup: pool may have shrunk below limit
|
||||
if limit != "auto" and len(valid_candidates) < limit:
|
||||
logger.warning(f"Only {len(valid_candidates)} embeddings after near-duplicate removal. Returning all.")
|
||||
logger.warning("Only %s embeddings after near-duplicate removal. Returning all.", len(valid_candidates))
|
||||
return valid_candidates
|
||||
|
||||
# --- Phase 6: Cluster-aware selection ---
|
||||
@@ -352,7 +356,7 @@ def _dedup_embeddings(
|
||||
|
||||
dropped = len(embeddings) - len(kept_indices)
|
||||
if dropped:
|
||||
logger.info(f"Near-duplicate removal dropped {dropped} images (threshold {_DEDUP_THRESHOLD}).")
|
||||
logger.info("Near-duplicate removal dropped %s images (threshold %s).", dropped, _DEDUP_THRESHOLD)
|
||||
|
||||
return (
|
||||
[embeddings[i] for i in kept_indices],
|
||||
@@ -447,7 +451,7 @@ def _compute_adaptive_threshold(emb_normed: np.ndarray) -> float:
|
||||
median_dist = float(np.median(upper_tri))
|
||||
threshold = max(0.05, median_dist * 0.20)
|
||||
|
||||
logger.debug(f"Adaptive threshold: {threshold:.4f} (median_dist={median_dist:.4f})")
|
||||
logger.debug("Adaptive threshold: %.4f (median_dist=%.4f)", threshold, median_dist)
|
||||
return threshold
|
||||
|
||||
|
||||
@@ -485,7 +489,7 @@ def _cluster_aware_selection(
|
||||
|
||||
# --- Stage 1: K-Medoids clustering ---
|
||||
k = min(max(5, target // 4), max(1, n // 3), n) # e.g., 1-20 clusters
|
||||
logger.debug(f"Clustering {n} embeddings into {k} groups (K-Medoids)...")
|
||||
logger.debug("Clustering %s embeddings into %s groups (K-Medoids)...", n, k)
|
||||
|
||||
# Compute full cosine distance matrix
|
||||
dist_matrix = 1 - emb_normed @ emb_normed.T
|
||||
@@ -494,7 +498,7 @@ def _cluster_aware_selection(
|
||||
selected = list(medoid_indices)
|
||||
selected_set = set(selected)
|
||||
|
||||
logger.debug(f"Selected {len(selected)} cluster medoids as initial picks.")
|
||||
logger.debug("Selected %s cluster medoids as initial picks.", len(selected))
|
||||
|
||||
# --- Stage 2: FPS with hard example weighting ---
|
||||
min_dists = np.full(n, np.inf)
|
||||
@@ -534,7 +538,7 @@ def _cluster_aware_selection(
|
||||
|
||||
selected_conf = [conf_array[i] for i in selected if conf_array[i] < 1.0]
|
||||
hard_count = sum(1 for c in selected_conf if c < 0.85)
|
||||
logger.info(f"Selection complete: {len(selected)} images ({hard_count} hard examples with confidence < 0.85).")
|
||||
logger.info("Selection complete: %s images (%s hard examples with confidence < 0.85).", len(selected), hard_count)
|
||||
|
||||
return [candidates[i] for i in selected]
|
||||
|
||||
@@ -549,7 +553,7 @@ def _select_time_spread(assets: list, limit: int | str) -> list:
|
||||
if limit == "auto":
|
||||
limit = 30
|
||||
|
||||
logger.info(f"Selecting {limit} images using time spread.")
|
||||
logger.info("Selecting %s images using time spread.", limit)
|
||||
|
||||
if len(assets) <= limit:
|
||||
return assets
|
||||
|
||||
@@ -66,7 +66,7 @@ def _preload_cuda_libs() -> None:
|
||||
else:
|
||||
logger.debug("onnxruntime.preload_dlls() not available (ORT < 1.21)")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to preload CUDA/cuDNN DLLs: {e}")
|
||||
logger.warning("Failed to preload CUDA/cuDNN DLLs: %s", e)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -100,7 +100,7 @@ def get_insightface_app():
|
||||
|
||||
# Get providers, excluding TensorRT to avoid noisy errors
|
||||
providers = [p for p in ort.get_available_providers() if p != "TensorrtExecutionProvider"]
|
||||
logger.debug(f"ONNX providers available: {providers}")
|
||||
logger.debug("ONNX providers available: %s", providers)
|
||||
|
||||
gpu_providers = {
|
||||
"CUDAExecutionProvider",
|
||||
@@ -121,7 +121,7 @@ def get_insightface_app():
|
||||
if p == "OpenVINOExecutionProvider" else p
|
||||
for p in providers
|
||||
]
|
||||
logger.debug(f"OpenVINO EP: device_type={openvino_device}")
|
||||
logger.debug("OpenVINO EP: device_type=%s", openvino_device)
|
||||
|
||||
if not has_gpu_provider and not _is_force_cpu():
|
||||
logger.warning(
|
||||
@@ -136,21 +136,21 @@ def get_insightface_app():
|
||||
device_str = f"OpenVINO ({os.getenv('OPENVINO_DEVICE', 'CPU')})"
|
||||
else:
|
||||
device_str = "GPU"
|
||||
logger.info(f"InsightFace Buffalo_L: loading into memory on {device_str}...")
|
||||
logger.info("InsightFace Buffalo_L: loading into memory on %s...", device_str)
|
||||
|
||||
t0 = time.time()
|
||||
with _suppress_output():
|
||||
_insightface_app = FaceAnalysis(name="buffalo_l", root=insightface_home, providers=providers)
|
||||
_insightface_app.prepare(ctx_id=ctx_id, det_size=(640, 640))
|
||||
|
||||
logger.info(f"InsightFace Buffalo_L: ready on {device_str} ({time.time() - t0:.1f}s)")
|
||||
logger.info("InsightFace Buffalo_L: ready on %s (%.1fs)", device_str, time.time() - t0)
|
||||
return _insightface_app
|
||||
|
||||
except ImportError:
|
||||
logger.error("InsightFace not installed!")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load InsightFace: {e}")
|
||||
logger.error("Failed to load InsightFace: %s", e)
|
||||
if ctx_id == 0:
|
||||
logger.warning("InsightFace GPU load failed — retrying on CPU...")
|
||||
try:
|
||||
@@ -164,10 +164,10 @@ def get_insightface_app():
|
||||
providers=["CPUExecutionProvider"],
|
||||
)
|
||||
_insightface_app.prepare(ctx_id=-1, det_size=(640, 640))
|
||||
logger.info(f"InsightFace Buffalo_L: ready on CPU (fallback, {time.time() - t0:.1f}s)")
|
||||
logger.info("InsightFace Buffalo_L: ready on CPU (fallback, %.1fs)", time.time() - t0)
|
||||
return _insightface_app
|
||||
except Exception as ex:
|
||||
logger.error(f"InsightFace CPU fallback failed: {ex}")
|
||||
logger.error("InsightFace CPU fallback failed: %s", ex)
|
||||
return None
|
||||
|
||||
|
||||
@@ -193,7 +193,7 @@ def get_face_embedding(img_pil: Image.Image) -> np.ndarray | None:
|
||||
largest = max(faces, key=lambda f: (f.bbox[2] - f.bbox[0]) * (f.bbox[3] - f.bbox[1]))
|
||||
return largest.embedding
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting face embedding: {e}")
|
||||
logger.error("Error getting face embedding: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
+22
-124
@@ -3,7 +3,6 @@
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from io import BytesIO
|
||||
from urllib.parse import quote
|
||||
|
||||
@@ -21,9 +20,10 @@ from .frigate_api import (
|
||||
recognize_face,
|
||||
)
|
||||
from .image_processing import process_face_mode
|
||||
from .immich_api import fetch_face_data, fetch_full_image
|
||||
from .immich_api import fetch_full_image
|
||||
from .log_config import console
|
||||
from .quality import assess_quality
|
||||
from .reconcile import enrich_asset_with_face_data, reconcile_frigate_mappings
|
||||
from .upload_tracker import (
|
||||
get_lowest_quality_mapped_file,
|
||||
get_most_redundant_mapped_file,
|
||||
@@ -32,7 +32,6 @@ from .upload_tracker import (
|
||||
has_frigate_scores,
|
||||
mark_rejected,
|
||||
mark_uploaded,
|
||||
record_frigate_files_batch,
|
||||
remove_frigate_file,
|
||||
)
|
||||
|
||||
@@ -55,112 +54,6 @@ def _safe_person_dir(output_dir: str, person_name: str) -> str:
|
||||
return candidate
|
||||
|
||||
|
||||
def _reconcile_frigate_mappings(
|
||||
person_name: str,
|
||||
known_files_before: set[str],
|
||||
uploaded: list[tuple[str, str | None]],
|
||||
) -> None:
|
||||
"""Map Frigate filenames to asset IDs after a batch of uploads.
|
||||
|
||||
Polls until all expected new files appear in the Frigate API, then maps
|
||||
them to asset IDs by filename timestamp order (Frigate processes the
|
||||
upload queue in FIFO order, so earlier uploads get earlier timestamps).
|
||||
|
||||
KNOWN LIMITATION — race condition with external uploads:
|
||||
If another client uploads a face file for this person concurrently, the
|
||||
count of new files will exceed `len(uploaded)` and we bail out entirely
|
||||
(the "> target" branch). That's safe — we never record a wrong mapping —
|
||||
but those uploads become permanently unmapped (they won't be eligible for
|
||||
quality replacement). The right fix is a Frigate API that returns the
|
||||
filename in the upload response, removing the need for any post-upload
|
||||
diffing. Until then, the external-upload guard keeps mappings correct at
|
||||
the cost of occasionally missing them when another client is active.
|
||||
"""
|
||||
target = len(uploaded)
|
||||
current_files: set[str] = set()
|
||||
|
||||
for delay in (1, 2, 4, 8):
|
||||
time.sleep(delay)
|
||||
fresh = get_frigate_person_files(person_name)
|
||||
if fresh is None:
|
||||
logger.warning(
|
||||
f"{person_name}: Frigate API unreachable during mapping reconciliation"
|
||||
" — quality replacement won't target these files"
|
||||
)
|
||||
return
|
||||
current_files = set(fresh)
|
||||
if len(current_files - known_files_before) >= target:
|
||||
break
|
||||
|
||||
new_files = current_files - known_files_before
|
||||
|
||||
if len(new_files) == target:
|
||||
def _ts(fname: str) -> float:
|
||||
try:
|
||||
return float(fname.rsplit("_", 1)[-1].replace(".webp", ""))
|
||||
except (ValueError, IndexError):
|
||||
return 0.0
|
||||
|
||||
mappings = {
|
||||
frigate_file: asset_id
|
||||
for (_, asset_id), frigate_file in zip(uploaded, sorted(new_files, key=_ts))
|
||||
if asset_id
|
||||
}
|
||||
record_frigate_files_batch(person_name, mappings)
|
||||
elif len(new_files) > target:
|
||||
logger.info(
|
||||
f"{person_name}: {len(new_files)} new Frigate files for {target} uploads"
|
||||
" (external upload detected) — skipping file mapping"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"{person_name}: only {len(new_files)} of {target} expected Frigate files"
|
||||
" appeared after reconciliation — mapping skipped"
|
||||
)
|
||||
|
||||
|
||||
def _enrich_asset_with_face_data(asset: dict, person: dict) -> dict:
|
||||
"""Enrich an asset dict with face bounding box data from the Immich faces API.
|
||||
|
||||
The search/metadata endpoint does not include face bounding box data,
|
||||
so we fetch it from GET /api/faces?id={asset_id} and inject it into
|
||||
the asset's "people" field so process_face_mode can find it.
|
||||
|
||||
Returns the enriched asset dict (modifies in place and returns it).
|
||||
"""
|
||||
person_id = person["id"]
|
||||
face_data = fetch_face_data(asset["id"], person_id=person_id)
|
||||
|
||||
if face_data is None:
|
||||
logger.debug(f"No face data returned for {person.get('name')} in asset {asset.get('id')}")
|
||||
# Clean any None entries from the people list (can come from Immich API)
|
||||
if "people" in asset:
|
||||
asset["people"] = [p for p in asset["people"] if p is not None]
|
||||
return asset
|
||||
|
||||
# Skip zero-area bounding boxes (face detection failed or no face found)
|
||||
if face_data.bbox == (0, 0, 0, 0):
|
||||
logger.debug(f"Zero-area bounding box for {person.get('name')} in asset {asset.get('id')}")
|
||||
# Clean any None entries from the people list (can come from Immich API)
|
||||
if "people" in asset:
|
||||
asset["people"] = [p for p in asset["people"] if p is not None]
|
||||
return asset
|
||||
|
||||
face_info = {
|
||||
"boundingBoxX1": face_data.bbox[0],
|
||||
"boundingBoxY1": face_data.bbox[1],
|
||||
"boundingBoxX2": face_data.bbox[2],
|
||||
"boundingBoxY2": face_data.bbox[3],
|
||||
"imageWidth": face_data.image_width,
|
||||
"imageHeight": face_data.image_height,
|
||||
}
|
||||
|
||||
# Inject into asset so process_face_mode can find it via asset["people"]
|
||||
asset["people"] = [{"id": person_id, "faces": [face_info]}]
|
||||
asset["face_confidence"] = face_data.confidence
|
||||
return asset
|
||||
|
||||
|
||||
def execute_jobs(jobs: list[dict]) -> None:
|
||||
"""Download and process images for all jobs.
|
||||
|
||||
@@ -184,7 +77,7 @@ def execute_jobs(jobs: list[dict]) -> None:
|
||||
|
||||
insightface_app = get_insightface_app()
|
||||
except Exception as e:
|
||||
logger.debug(f"InsightFace unavailable for crop alignment: {e}")
|
||||
logger.debug("InsightFace unavailable for crop alignment: %s", e)
|
||||
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
@@ -221,7 +114,7 @@ def execute_jobs(jobs: list[dict]) -> None:
|
||||
try:
|
||||
# Enrich the asset with face bounding box data from the Immich
|
||||
# faces API (not included in search/metadata results).
|
||||
asset = _enrich_asset_with_face_data(asset, person)
|
||||
asset = enrich_asset_with_face_data(asset, person)
|
||||
# Skip download if detection confidence already disqualifies
|
||||
# the asset — avoids fetching a large image we'll discard.
|
||||
conf = asset.get("face_confidence")
|
||||
@@ -271,7 +164,7 @@ def execute_jobs(jobs: list[dict]) -> None:
|
||||
score_img.thumbnail((1440, 1440), Image.LANCZOS)
|
||||
score_map[filename] = assess_quality(score_img).blur_score
|
||||
except Exception as exc:
|
||||
logger.debug(f"Quality score fallback for {asset['id']}: {exc}")
|
||||
logger.debug("Quality score fallback for %s: %s", asset["id"], exc)
|
||||
score_map[filename] = 0.0 # unknown quality — treat as lowest
|
||||
|
||||
count += 1
|
||||
@@ -280,7 +173,7 @@ def execute_jobs(jobs: list[dict]) -> None:
|
||||
f"[yellow]Skipped {asset['id']} (no usable face data)[/yellow]"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to process asset {asset['id']}: {e}")
|
||||
logger.error("Failed to process asset %s: %s", asset["id"], e)
|
||||
|
||||
progress.advance(job_task)
|
||||
progress.advance(overall_task)
|
||||
@@ -294,7 +187,7 @@ def execute_jobs(jobs: list[dict]) -> None:
|
||||
|
||||
# Log how many images were actually saved vs selected
|
||||
if count < len(assets):
|
||||
logger.info(f"{name}: saved {count}/{len(assets)} selected images")
|
||||
logger.info("%s: saved %s/%s selected images", name, count, len(assets))
|
||||
|
||||
|
||||
def upload_to_frigate(jobs: list[dict]) -> None:
|
||||
@@ -406,16 +299,21 @@ def upload_to_frigate(jobs: list[dict]) -> None:
|
||||
else get_frigate_person_files(name)
|
||||
)
|
||||
if _snapshot is None:
|
||||
# Frigate GET is down; fall back to the tracker's mapped filenames
|
||||
# as the pre-upload baseline. reconciliation will still work unless
|
||||
# there are concurrent manual uploads (handled by >target guard).
|
||||
# Frigate GET is down. The tracker only knows files winnow mapped
|
||||
# previously — it is blind to manually-added Frigate files. Using
|
||||
# the tracker as the baseline would make those unmapped files look
|
||||
# like new uploads in reconcile, triggering the >target guard and
|
||||
# silently dropping all mappings. Skip reconciliation entirely when
|
||||
# we can't get a reliable live snapshot.
|
||||
logger.warning(
|
||||
f"{name}: Frigate API unreachable at upload start"
|
||||
" — using tracker baseline for post-upload reconciliation"
|
||||
"%s: Frigate API unreachable at upload start"
|
||||
" — file mapping will be skipped for this batch", name
|
||||
)
|
||||
known_frigate_files_at_start: set[str] = get_tracked_frigate_filenames(name)
|
||||
known_frigate_files_at_start: set[str] = set()
|
||||
_skip_reconcile = True
|
||||
else:
|
||||
known_frigate_files_at_start: set[str] = set(_snapshot)
|
||||
_skip_reconcile = False
|
||||
# Remove tracker mappings for files that no longer exist in Frigate
|
||||
# (manually deleted, or cleaned up outside winnow). This corrects the
|
||||
# effective_count so those slots are available for new uploads.
|
||||
@@ -558,7 +456,7 @@ def upload_to_frigate(jobs: list[dict]) -> None:
|
||||
effective_count -= 1
|
||||
min_quality_score_for_slot = None if using_fscore else candidate_score
|
||||
else:
|
||||
logger.warning(f"Failed to delete {target_frigate_file} for {name}, skipping replacement")
|
||||
logger.warning("Failed to delete %s for %s, skipping replacement", target_frigate_file, name)
|
||||
failed_deletes.add(target_frigate_file)
|
||||
progress.advance(upload_task)
|
||||
continue
|
||||
@@ -611,7 +509,7 @@ def upload_to_frigate(jobs: list[dict]) -> None:
|
||||
if resp.status_code == 400:
|
||||
progress.console.print(f" [dim]{error_detail}[/dim]")
|
||||
else:
|
||||
logger.debug(f"{fname} HTTP {resp.status_code}: {error_detail}")
|
||||
logger.debug("%s HTTP %s: %s", fname, resp.status_code, error_detail)
|
||||
if resp.status_code == 400 and "face" in full_body.lower():
|
||||
asset_id = asset_map.get(fname)
|
||||
if asset_id:
|
||||
@@ -655,8 +553,8 @@ def upload_to_frigate(jobs: list[dict]) -> None:
|
||||
)
|
||||
|
||||
# Batch-map Frigate filenames to asset IDs now that all uploads are done.
|
||||
if actually_uploaded:
|
||||
_reconcile_frigate_mappings(name, known_frigate_files_at_start, actually_uploaded)
|
||||
if actually_uploaded and not _skip_reconcile:
|
||||
reconcile_frigate_mappings(name, known_frigate_files_at_start, actually_uploaded)
|
||||
|
||||
# Per-person summary
|
||||
if person_failed == 0:
|
||||
|
||||
@@ -36,7 +36,7 @@ def _get_faces_data() -> dict | None:
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not query Frigate faces API: {e}")
|
||||
logger.warning("Could not query Frigate faces API: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ def recognize_face(file_path: str) -> tuple[str | None, float] | None:
|
||||
return (data.get("face_name"), round(float(data["score"]), 4))
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug(f"Frigate recognize failed for {file_path}: {e}")
|
||||
logger.debug("Frigate recognize failed for %s: %s", file_path, e)
|
||||
return None
|
||||
|
||||
|
||||
@@ -147,15 +147,15 @@ def delete_frigate_person_files(person_name: str, filenames: list[str]) -> bool:
|
||||
timeout=10,
|
||||
)
|
||||
if resp.ok:
|
||||
logger.debug(f"Deleted {len(filenames)} Frigate file(s) for {person_name}")
|
||||
logger.debug("Deleted %s Frigate file(s) for %s", len(filenames), person_name)
|
||||
return True
|
||||
if resp.status_code == 404:
|
||||
# File already absent — stale tracker entry. Return True so the caller
|
||||
# removes it from the tracker and frees the slot cleanly.
|
||||
logger.warning(f"Frigate file(s) not found for {person_name} (stale tracker entry?): {filenames}")
|
||||
logger.warning("Frigate file(s) not found for %s (stale tracker entry?): %s", person_name, filenames)
|
||||
return True
|
||||
logger.warning(f"Frigate delete returned {resp.status_code} for {person_name}")
|
||||
logger.warning("Frigate delete returned %s for %s", resp.status_code, person_name)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete Frigate files for {person_name}: {e}")
|
||||
logger.warning("Failed to delete Frigate files for %s: %s", person_name, e)
|
||||
return False
|
||||
|
||||
@@ -37,7 +37,7 @@ def align_face(img: Image.Image, landmarks: list[list[float]] | np.ndarray) -> I
|
||||
img_np = np.asarray(img)
|
||||
lm = np.array(landmarks, dtype=np.float32)
|
||||
if lm.shape != (5, 2):
|
||||
logger.debug(f"Invalid landmark shape: {lm.shape}, expected (5, 2)")
|
||||
logger.debug("Invalid landmark shape: %s, expected (5, 2)", lm.shape)
|
||||
return None
|
||||
aligned = norm_crop(img_np, lm)
|
||||
return Image.fromarray(aligned)
|
||||
@@ -45,7 +45,7 @@ def align_face(img: Image.Image, landmarks: list[list[float]] | np.ndarray) -> I
|
||||
logger.debug("InsightFace not available for face alignment")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug(f"Face alignment failed: {e}")
|
||||
logger.debug("Face alignment failed: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ def process_face_mode(
|
||||
break
|
||||
|
||||
if not face_info:
|
||||
logger.debug(f"No face info for {person.get('name')} in asset {asset.get('id')}")
|
||||
logger.debug("No face info for %s in asset %s", person.get("name"), asset.get("id"))
|
||||
return None
|
||||
|
||||
img_w, img_h = img.size
|
||||
@@ -95,7 +95,7 @@ def process_face_mode(
|
||||
|
||||
face_w, face_h = x2 - x1, y2 - y1
|
||||
if face_w < min_width or face_h < min_width:
|
||||
logger.debug(f"Face too small ({face_w:.1f}x{face_h:.1f})")
|
||||
logger.debug("Face too small (%.1fx%.1f)", face_w, face_h)
|
||||
return None
|
||||
|
||||
# Re-detect face with InsightFace for landmark-based alignment.
|
||||
@@ -131,7 +131,7 @@ def process_face_mode(
|
||||
_save_jpeg(aligned, os.path.join(output_dir, f"{count}.jpg"))
|
||||
return aligned.size
|
||||
except Exception as e:
|
||||
logger.debug(f"InsightFace re-detection failed for {asset.get('id')}: {e}")
|
||||
logger.debug("InsightFace re-detection failed for %s: %s", asset.get("id"), e)
|
||||
|
||||
# Landmark alignment from Immich metadata (Immich does not currently
|
||||
# expose landmarks, so this path is a future-proofing fallback)
|
||||
|
||||
+14
-14
@@ -59,7 +59,7 @@ def get_people() -> list[dict]:
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("people", [])
|
||||
except (requests.RequestException, ValueError) as e:
|
||||
logger.error(f"Failed to fetch people from Immich: {e}")
|
||||
logger.error("Failed to fetch people from Immich: %s", e)
|
||||
return []
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ def merge_people(survivor_id: str, merge_ids: list[str]) -> bool:
|
||||
resp.raise_for_status()
|
||||
return True
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Failed to merge people into {survivor_id}: {e}")
|
||||
logger.error("Failed to merge people into %s: %s", survivor_id, e)
|
||||
return False
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ def fetch_all_assets(person: dict) -> list[dict]:
|
||||
url = f"{Config.IMMICH_URL}/api/search/metadata"
|
||||
page_size = 1000
|
||||
|
||||
logger.debug(f"Fetching assets for {name}...")
|
||||
logger.debug("Fetching assets for %s...", name)
|
||||
|
||||
assets = []
|
||||
for page in range(1, MAX_PAGES + 1):
|
||||
@@ -103,7 +103,7 @@ def fetch_all_assets(person: dict) -> list[dict]:
|
||||
)
|
||||
|
||||
if not resp.ok:
|
||||
logger.error(f"Error fetching assets for {name} (page {page}): {resp.status_code}")
|
||||
logger.error("Error fetching assets for %s (page %s): %s", name, page, resp.status_code)
|
||||
break
|
||||
|
||||
page_assets = resp.json().get("assets", [])
|
||||
@@ -114,13 +114,13 @@ def fetch_all_assets(person: dict) -> list[dict]:
|
||||
break
|
||||
|
||||
assets.extend(a for a in page_assets if isinstance(a, dict))
|
||||
logger.debug(f"Fetched page {page}, total: {len(assets)}")
|
||||
logger.debug("Fetched page %s, total: %s", page, len(assets))
|
||||
|
||||
if len(page_assets) < page_size or len(assets) >= _MAX_ASSETS_PER_PERSON:
|
||||
break
|
||||
|
||||
except (requests.RequestException, ValueError) as e:
|
||||
logger.error(f"Exception fetching assets for {name}: {e}")
|
||||
logger.error("Exception fetching assets for %s: %s", name, e)
|
||||
break
|
||||
|
||||
return assets
|
||||
@@ -148,7 +148,7 @@ def fetch_face_data(asset_id: str, person_id: str | None = None) -> FaceData | N
|
||||
)
|
||||
|
||||
if not resp.ok:
|
||||
logger.debug(f"Face data endpoint returned {resp.status_code} for {asset_id}")
|
||||
logger.debug("Face data endpoint returned %s for %s", resp.status_code, asset_id)
|
||||
return None
|
||||
|
||||
faces = resp.json()
|
||||
@@ -181,10 +181,10 @@ def fetch_face_data(asset_id: str, person_id: str | None = None) -> FaceData | N
|
||||
)
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.debug(f"Failed to fetch face data for {asset_id}: {e}")
|
||||
logger.debug("Failed to fetch face data for %s: %s", asset_id, e)
|
||||
return None
|
||||
except (AttributeError, KeyError, TypeError, ValueError) as e:
|
||||
logger.debug(f"Failed to parse face data for {asset_id}: {e}")
|
||||
logger.debug("Failed to parse face data for %s: %s", asset_id, e)
|
||||
return None
|
||||
|
||||
|
||||
@@ -205,9 +205,9 @@ def fetch_full_image(asset_id: str, timeout: int = 60) -> Image.Image | None:
|
||||
try:
|
||||
return ImageOps.exif_transpose(Image.open(BytesIO(resp.content)))
|
||||
except Exception:
|
||||
logger.debug(f"PIL can't open original for {asset_id}, falling back to preview")
|
||||
logger.debug("PIL can't open original for %s, falling back to preview", asset_id)
|
||||
except requests.RequestException:
|
||||
logger.debug(f"Original request failed for {asset_id}, falling back to preview")
|
||||
logger.debug("Original request failed for %s, falling back to preview", asset_id)
|
||||
|
||||
# Fall back to preview thumbnail (always JPEG)
|
||||
try:
|
||||
@@ -219,7 +219,7 @@ def fetch_full_image(asset_id: str, timeout: int = 60) -> Image.Image | None:
|
||||
if resp.ok:
|
||||
return ImageOps.exif_transpose(Image.open(BytesIO(resp.content)))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch image {asset_id}: {e}")
|
||||
logger.error("Failed to fetch image %s: %s", asset_id, e)
|
||||
|
||||
return None
|
||||
|
||||
@@ -229,7 +229,7 @@ def filter_recent_assets(assets: list[dict], years: int | None = None) -> list[d
|
||||
years = years or Config.YEARS_FILTER
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=365 * years)
|
||||
|
||||
logger.debug(f"Filtering assets older than {years} years ({cutoff})")
|
||||
logger.debug("Filtering assets older than %s years (%s)", years, cutoff)
|
||||
|
||||
recent, skipped = [], 0
|
||||
for asset in assets:
|
||||
@@ -247,6 +247,6 @@ def filter_recent_assets(assets: list[dict], years: int | None = None) -> list[d
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
logger.debug(f"Retained {len(recent)} assets (filtered {skipped} old assets).")
|
||||
logger.debug("Retained %s assets (filtered %s old assets).", len(recent), skipped)
|
||||
return recent
|
||||
|
||||
|
||||
+53
-35
@@ -120,14 +120,33 @@ def _perform_selection(
|
||||
return selected
|
||||
|
||||
|
||||
def _build_job(
|
||||
person: dict,
|
||||
assets: list,
|
||||
limit: int | str,
|
||||
selection_mode: str,
|
||||
quality_replacement: bool = False,
|
||||
) -> dict | None:
|
||||
"""Select from pre-filtered assets and build a job dict. No terminal I/O."""
|
||||
if not assets:
|
||||
return None
|
||||
name = person["name"]
|
||||
selected = _perform_selection(assets, limit, name, selection_mode, person_id=person["id"])
|
||||
if not selected:
|
||||
return None
|
||||
return {
|
||||
"person": person,
|
||||
"assets": selected,
|
||||
"limit": len(selected),
|
||||
"config": {"name": name, "quality_replacement": quality_replacement},
|
||||
}
|
||||
|
||||
|
||||
def _configure_person(person: dict, people: list[dict]) -> dict | None:
|
||||
"""Configure training for a single person. Returns job dict or None."""
|
||||
name = person["name"]
|
||||
console.print(f"\nSelected: [bold green]{name}[/bold green]")
|
||||
|
||||
config = {"name": name, "quality_replacement": Config.QUALITY_REPLACEMENT}
|
||||
|
||||
# Fetch and filter assets
|
||||
years = IntPrompt.ask("Filter images older than (years)", default=Config.YEARS_FILTER)
|
||||
|
||||
console.print(f"Scanning for {name}...")
|
||||
@@ -137,11 +156,10 @@ def _configure_person(person: dict, people: list[dict]) -> dict | None:
|
||||
|
||||
rprint(f" Found [bold]{len(all_assets)}[/bold] total, [bold]{len(recent_assets)}[/bold] in range ({years} years).")
|
||||
|
||||
# Filter out assets already uploaded to Frigate.
|
||||
# In interactive mode, ask — use the env var only as the default so it can
|
||||
# still be pre-set (e.g. RETRY_REJECTED=true) without forcing the answer.
|
||||
# Ask before strategy so the post-dedup count can inform the choice
|
||||
retry_env = os.environ.get("RETRY_REJECTED", "false").lower() in ("true", "1", "yes")
|
||||
retry_rejected = Confirm.ask("Include previously rejected images?", default=retry_env)
|
||||
|
||||
before_dedup = len(recent_assets)
|
||||
new_asset_ids = set(filter_already_uploaded([a["id"] for a in recent_assets], retry_rejected=retry_rejected))
|
||||
recent_assets = [a for a in recent_assets if a["id"] in new_asset_ids]
|
||||
@@ -153,19 +171,19 @@ def _configure_person(person: dict, people: list[dict]) -> dict | None:
|
||||
rprint(" [dim]Skipping (0 new images after dedup).[/dim]")
|
||||
return None
|
||||
|
||||
# Strategy selection
|
||||
has_embedding = is_embedding_available()
|
||||
rprint(f"\n[bold cyan]Select Training Strategy for {name}:[/bold cyan]")
|
||||
|
||||
limit, selection_mode = _get_strategy_choice(has_embedding)
|
||||
if selection_mode == "skip":
|
||||
return None
|
||||
|
||||
# Perform selection
|
||||
selected_assets = _perform_selection(recent_assets, limit, name, selection_mode, person_id=person["id"])
|
||||
job = _build_job(person, recent_assets, limit, selection_mode, quality_replacement=Config.QUALITY_REPLACEMENT)
|
||||
if job is None:
|
||||
rprint(" [dim]Skipping (0 images selected).[/dim]")
|
||||
return None
|
||||
|
||||
rprint(f" [green]Queued {len(selected_assets)} images for {name}.[/green]")
|
||||
return {"person": person, "assets": selected_assets, "limit": len(selected_assets), "config": config}
|
||||
rprint(f" [green]Queued {job['limit']} images for {name}.[/green]")
|
||||
return job
|
||||
|
||||
|
||||
def interactive_configure(people: list[dict]) -> list[dict]:
|
||||
@@ -240,26 +258,12 @@ def auto_configure(people: list[dict]) -> list[dict]:
|
||||
jobs = []
|
||||
for person in valid_people:
|
||||
name = person["name"]
|
||||
config = {"name": name}
|
||||
|
||||
all_assets = fetch_all_assets(person)
|
||||
recent_assets = filter_recent_assets(all_assets, years=Config.YEARS_FILTER)
|
||||
|
||||
rprint(f" {name}: {len(all_assets)} total, {len(recent_assets)} recent")
|
||||
|
||||
# Filter out assets already uploaded to Frigate
|
||||
retry_rejected = os.environ.get("RETRY_REJECTED", "false").lower() in ("true", "1", "yes")
|
||||
before_dedup = len(recent_assets)
|
||||
new_asset_ids = set(filter_already_uploaded([a["id"] for a in recent_assets], retry_rejected=retry_rejected))
|
||||
recent_assets = [a for a in recent_assets if a["id"] in new_asset_ids]
|
||||
skipped = before_dedup - len(recent_assets)
|
||||
if skipped:
|
||||
rprint(f" [dim]Skipped {skipped} assets already uploaded to Frigate.[/dim]")
|
||||
|
||||
if not recent_assets:
|
||||
rprint(f" [dim]Skipping {name} (0 new images after dedup).[/dim]")
|
||||
continue
|
||||
|
||||
# Enforce MAX_AUTO_IMAGES against the tracked file count only.
|
||||
# Manually-added Frigate files are invisible to this cap so users can
|
||||
# curate their own files without shrinking winnow's managed quota.
|
||||
@@ -281,31 +285,45 @@ def auto_configure(people: list[dict]) -> list[dict]:
|
||||
else:
|
||||
quality_replacement_only = False
|
||||
|
||||
config["quality_replacement"] = quality_replacement_only or Config.QUALITY_REPLACEMENT
|
||||
quality_replacement = quality_replacement_only or Config.QUALITY_REPLACEMENT
|
||||
|
||||
has_embedding = is_embedding_available()
|
||||
limit, selection_mode = _resolve_strategy(strategy, has_embedding)
|
||||
|
||||
# Cap selection to remaining capacity (no cap when replacement-only — executor
|
||||
# decides per-image whether to swap; any candidate could be an improvement).
|
||||
auto_cap = None
|
||||
if not quality_replacement_only:
|
||||
if limit == "auto":
|
||||
# Switch from open-ended auto to a fixed budget at remaining capacity
|
||||
# so the diversity selector itself stops at the right count instead of
|
||||
# selecting MAX_AUTO_IMAGES and then discarding the excess by position.
|
||||
if already_uploaded > 0:
|
||||
auto_cap = capacity
|
||||
limit = capacity
|
||||
else:
|
||||
limit = min(limit, capacity)
|
||||
|
||||
if selection_mode == "skip":
|
||||
continue
|
||||
|
||||
selected_assets = _perform_selection(recent_assets, limit, name, selection_mode, person_id=person["id"])
|
||||
if auto_cap is not None:
|
||||
selected_assets = selected_assets[:auto_cap]
|
||||
retry_rejected = os.environ.get("RETRY_REJECTED", "false").lower() in ("true", "1", "yes")
|
||||
before_dedup = len(recent_assets)
|
||||
new_asset_ids = set(filter_already_uploaded([a["id"] for a in recent_assets], retry_rejected=retry_rejected))
|
||||
recent_assets = [a for a in recent_assets if a["id"] in new_asset_ids]
|
||||
skipped = before_dedup - len(recent_assets)
|
||||
if skipped:
|
||||
rprint(f" [dim]Skipped {skipped} assets already uploaded to Frigate.[/dim]")
|
||||
|
||||
if selected_assets:
|
||||
rprint(f" [green]Queued {len(selected_assets)} images for {name}.[/green]")
|
||||
jobs.append({"person": person, "assets": selected_assets, "limit": len(selected_assets), "config": config})
|
||||
if not recent_assets:
|
||||
rprint(f" [dim]Skipping {name} (0 new images after dedup).[/dim]")
|
||||
continue
|
||||
|
||||
job = _build_job(person, recent_assets, limit, selection_mode, quality_replacement=quality_replacement)
|
||||
if job is None:
|
||||
rprint(f" [dim]Skipping {name} (0 images selected).[/dim]")
|
||||
continue
|
||||
|
||||
rprint(f" [green]Queued {job['limit']} images for {name}.[/green]")
|
||||
jobs.append(job)
|
||||
|
||||
return jobs
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Frigate upload post-processing: reconciliation and asset enrichment."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from .frigate_api import get_frigate_person_files
|
||||
from .immich_api import fetch_face_data
|
||||
from .upload_tracker import record_frigate_files_batch
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def reconcile_frigate_mappings(
|
||||
person_name: str,
|
||||
known_files_before: set[str],
|
||||
uploaded: list[tuple[str, str | None]],
|
||||
) -> None:
|
||||
"""Map Frigate filenames to asset IDs after a batch of uploads.
|
||||
|
||||
Polls until all expected new files appear in the Frigate API, then maps
|
||||
them to asset IDs by filename timestamp order (Frigate processes the
|
||||
upload queue in FIFO order, so earlier uploads get earlier timestamps).
|
||||
|
||||
KNOWN LIMITATION — race condition with external uploads:
|
||||
If another client uploads a face file for this person concurrently, the
|
||||
count of new files will exceed `len(uploaded)` and we bail out entirely
|
||||
(the "> target" branch). That's safe — we never record a wrong mapping —
|
||||
but those uploads become permanently unmapped (they won't be eligible for
|
||||
quality replacement). The right fix is a Frigate API that returns the
|
||||
filename in the upload response, removing the need for any post-upload
|
||||
diffing. Until then, the external-upload guard keeps mappings correct at
|
||||
the cost of occasionally missing them when another client is active.
|
||||
"""
|
||||
target = len(uploaded)
|
||||
current_files: set[str] = set()
|
||||
|
||||
for delay in (1, 2, 4, 8):
|
||||
time.sleep(delay)
|
||||
fresh = get_frigate_person_files(person_name)
|
||||
if fresh is None:
|
||||
logger.warning(
|
||||
"%s: Frigate API unreachable during mapping reconciliation"
|
||||
" — quality replacement won't target these files",
|
||||
person_name,
|
||||
)
|
||||
return
|
||||
current_files = set(fresh)
|
||||
new_count = len(current_files - known_files_before)
|
||||
if new_count == target:
|
||||
break
|
||||
if new_count > target:
|
||||
break # external upload already visible — no point polling further
|
||||
|
||||
new_files = current_files - known_files_before
|
||||
|
||||
if len(new_files) == target:
|
||||
def _ts(fname: str) -> float:
|
||||
try:
|
||||
return float(fname.rsplit("_", 1)[-1].replace(".webp", ""))
|
||||
except (ValueError, IndexError):
|
||||
return 0.0
|
||||
|
||||
mappings = {
|
||||
frigate_file: asset_id
|
||||
for (_, asset_id), frigate_file in zip(uploaded, sorted(new_files, key=_ts))
|
||||
if asset_id
|
||||
}
|
||||
record_frigate_files_batch(person_name, mappings)
|
||||
elif len(new_files) > target:
|
||||
logger.info(
|
||||
"%s: %s new Frigate files for %s uploads"
|
||||
" (external upload detected) — skipping file mapping",
|
||||
person_name,
|
||||
len(new_files),
|
||||
target,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"%s: only %s of %s expected Frigate files"
|
||||
" appeared after reconciliation — mapping skipped",
|
||||
person_name,
|
||||
len(new_files),
|
||||
target,
|
||||
)
|
||||
|
||||
|
||||
def enrich_asset_with_face_data(asset: dict, person: dict) -> dict:
|
||||
"""Enrich an asset dict with face bounding box data from the Immich faces API.
|
||||
|
||||
The search/metadata endpoint does not include face bounding box data,
|
||||
so we fetch it from GET /api/faces?id={asset_id} and inject it into
|
||||
the asset's "people" field so process_face_mode can find it.
|
||||
|
||||
Returns the enriched asset dict (modifies in place and returns it).
|
||||
"""
|
||||
person_id = person["id"]
|
||||
face_data = fetch_face_data(asset["id"], person_id=person_id)
|
||||
|
||||
if face_data is None:
|
||||
logger.debug("No face data returned for %s in asset %s", person.get("name"), asset.get("id"))
|
||||
# Clean any None entries from the people list (can come from Immich API)
|
||||
if "people" in asset:
|
||||
asset["people"] = [p for p in asset["people"] if p is not None]
|
||||
return asset
|
||||
|
||||
# Skip zero-area bounding boxes (face detection failed or no face found)
|
||||
if face_data.bbox == (0, 0, 0, 0):
|
||||
logger.debug("Zero-area bounding box for %s in asset %s", person.get("name"), asset.get("id"))
|
||||
# Clean any None entries from the people list (can come from Immich API)
|
||||
if "people" in asset:
|
||||
asset["people"] = [p for p in asset["people"] if p is not None]
|
||||
return asset
|
||||
|
||||
face_info = {
|
||||
"boundingBoxX1": face_data.bbox[0],
|
||||
"boundingBoxY1": face_data.bbox[1],
|
||||
"boundingBoxX2": face_data.bbox[2],
|
||||
"boundingBoxY2": face_data.bbox[3],
|
||||
"imageWidth": face_data.image_width,
|
||||
"imageHeight": face_data.image_height,
|
||||
}
|
||||
|
||||
# Inject into asset so process_face_mode can find it via asset["people"]
|
||||
asset["people"] = [{"id": person_id, "faces": [face_info]}]
|
||||
asset["face_confidence"] = face_data.confidence
|
||||
return asset
|
||||
+376
-273
@@ -1,146 +1,213 @@
|
||||
"""Persistent tracker for Immich asset IDs already uploaded/rejected by Frigate.
|
||||
"""Persistent tracker for Immich asset IDs uploaded/rejected by Frigate.
|
||||
|
||||
Two separate JSON files in CACHE_DIR:
|
||||
frigate_uploaded_ids.json — successfully uploaded assets
|
||||
frigate_rejected_ids.json — assets Frigate rejected (e.g. no face detected)
|
||||
Uses a local SQLite database (frigate_tracker.db) in CACHE_DIR.
|
||||
|
||||
Both are excluded from future candidate pools. To reset:
|
||||
- All: delete both files
|
||||
- One person: call reset_person("Name") or set RESET_PERSON=Name
|
||||
- Rejects only: delete frigate_rejected_ids.json, or set RETRY_REJECTED=true
|
||||
Schema
|
||||
------
|
||||
tracked_assets — one row per (asset_id, status) pair
|
||||
frigate_files — Frigate filename → Immich asset_id mapping
|
||||
person_metadata — last-known Frigate training image count per person
|
||||
|
||||
by_person schema (frigate_uploaded_ids.json):
|
||||
{
|
||||
"asset_ids": ["immich-id-1", ...], # all assets we attempted to upload
|
||||
"scores": {"immich-id-1": 450.3}, # Laplacian blur variance at upload time
|
||||
"frigate_scores": {"immich-id-1": 0.87}, # Frigate recognition confidence (0-1) pre-upload
|
||||
"frigate_files": {"PersonName-123.webp": "immich-id-1"}, # Frigate filename → asset ID
|
||||
"crop_dims": {"immich-id-1": [640, 480]}, # crop pixel dimensions at upload time
|
||||
"frigate_count": 42 # last known Frigate training image count
|
||||
}
|
||||
|
||||
frigate_scores stores pre-upload recognize scores (0-1 sigmoid-mapped cosine
|
||||
similarity). High score = the existing training set already covers this face
|
||||
condition well. Low score = a gap — novel/diverse for the training set.
|
||||
|
||||
frigate_files only contains files winnow uploaded — files added manually through
|
||||
Frigate's UI are never mapped here and are never touched by quality replacement.
|
||||
Migration
|
||||
---------
|
||||
On first open, if the old JSON files exist and the tables are empty, their
|
||||
data is migrated automatically. The JSON files are then renamed to .json.bak.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from .frigate_api import delete_frigate_person_files
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
UPLOAD_TRACKER_FILE = "frigate_uploaded_ids.json"
|
||||
REJECT_TRACKER_FILE = "frigate_rejected_ids.json"
|
||||
# Legacy JSON filenames (for migration)
|
||||
_UPLOAD_JSON = "frigate_uploaded_ids.json"
|
||||
_REJECT_JSON = "frigate_rejected_ids.json"
|
||||
_DB_NAME = "frigate_tracker.db"
|
||||
|
||||
# Write-through in-memory cache keyed by the resolved file path.
|
||||
# Reduces per-call JSON reads from O(calls) to O(1) after the first load.
|
||||
# Keyed by full path so tests with isolated tmp dirs never share entries.
|
||||
_cache: dict[str, dict] = {}
|
||||
_DDL = """
|
||||
CREATE TABLE IF NOT EXISTS tracked_assets (
|
||||
asset_id TEXT NOT NULL,
|
||||
person_name TEXT,
|
||||
status TEXT NOT NULL CHECK(status IN ('uploaded', 'rejected')),
|
||||
blur_score REAL,
|
||||
crop_width INTEGER,
|
||||
crop_height INTEGER,
|
||||
frigate_score REAL,
|
||||
PRIMARY KEY (asset_id, status)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS frigate_files (
|
||||
frigate_filename TEXT PRIMARY KEY,
|
||||
person_name TEXT NOT NULL,
|
||||
asset_id TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS person_metadata (
|
||||
person_name TEXT PRIMARY KEY,
|
||||
frigate_count INTEGER
|
||||
);
|
||||
"""
|
||||
|
||||
# Module-level connection state — re-opened when CACHE_DIR changes (test isolation)
|
||||
_conn: sqlite3.Connection | None = None
|
||||
_conn_path: str | None = None
|
||||
|
||||
|
||||
def _tracker_path(filename: str) -> Path:
|
||||
try:
|
||||
from .config import Config
|
||||
return Path(Config.CACHE_DIR) / filename
|
||||
except (ImportError, AttributeError):
|
||||
return Path(filename)
|
||||
def _get_conn() -> sqlite3.Connection:
|
||||
"""Return (or create) the module-level SQLite connection.
|
||||
|
||||
Re-opens the connection when Config.CACHE_DIR has changed — this provides
|
||||
test isolation when the isolated_cache fixture sets a new tmp directory and
|
||||
calls _Config.reset().
|
||||
"""
|
||||
global _conn, _conn_path
|
||||
|
||||
def _load(filename: str) -> dict:
|
||||
path = _tracker_path(filename)
|
||||
key = str(path)
|
||||
if key in _cache:
|
||||
return _cache[key]
|
||||
data: dict = {}
|
||||
if path.exists():
|
||||
from .config import Config
|
||||
cache_dir = Config.CACHE_DIR
|
||||
db_path = str(Path(cache_dir) / _DB_NAME)
|
||||
|
||||
if _conn is not None and _conn_path != db_path:
|
||||
try:
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.warning(f"Could not load tracker {filename}: {e}")
|
||||
_cache[key] = data
|
||||
return data
|
||||
_conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
_conn = None
|
||||
|
||||
if _conn is None:
|
||||
Path(cache_dir).mkdir(parents=True, exist_ok=True)
|
||||
_conn = sqlite3.connect(db_path, check_same_thread=False)
|
||||
_conn.row_factory = sqlite3.Row
|
||||
_conn.execute("PRAGMA journal_mode=WAL")
|
||||
_conn.execute("PRAGMA foreign_keys=ON")
|
||||
_conn.executescript(_DDL)
|
||||
_conn.commit()
|
||||
_conn_path = db_path
|
||||
_maybe_migrate(cache_dir, _conn)
|
||||
|
||||
return _conn
|
||||
|
||||
|
||||
def _save(filename: str, data: dict) -> None:
|
||||
path = _tracker_path(filename)
|
||||
_cache[str(path)] = data # keep cache consistent with what we write
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
# ---------------------------------------------------------------------------
|
||||
# JSON → SQLite migration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _maybe_migrate(cache_dir: str, conn: sqlite3.Connection) -> None:
|
||||
"""If the old JSON files exist and DB is empty, migrate and rename them."""
|
||||
base = Path(cache_dir)
|
||||
upload_json = base / _UPLOAD_JSON
|
||||
reject_json = base / _REJECT_JSON
|
||||
|
||||
if not upload_json.exists() and not reject_json.exists():
|
||||
return
|
||||
|
||||
# No row-count guard here: INSERT OR IGNORE makes migration idempotent, so it
|
||||
# is safe to re-run if a previous attempt renamed one file but not the other
|
||||
# (e.g. a PermissionError on the second rename would have left the first file's
|
||||
# data committed but the second file un-renamed and un-migrated).
|
||||
|
||||
logger.info("Migrating JSON tracker files to SQLite in %s", cache_dir)
|
||||
|
||||
try:
|
||||
with conn:
|
||||
if upload_json.exists():
|
||||
_migrate_json_data(conn, json.loads(upload_json.read_text()), "uploaded")
|
||||
if reject_json.exists():
|
||||
_migrate_json_data(conn, json.loads(reject_json.read_text()), "rejected")
|
||||
except Exception as exc:
|
||||
logger.warning("JSON migration failed, will retry next run: %s", exc)
|
||||
return
|
||||
|
||||
# Rename each file independently so a failure on one does not prevent the
|
||||
# other from being marked complete on this run.
|
||||
for json_path in (upload_json, reject_json):
|
||||
if json_path.exists():
|
||||
try:
|
||||
json_path.rename(json_path.with_suffix(".json.bak"))
|
||||
except OSError as exc:
|
||||
logger.warning("Could not rename %s after migration: %s", json_path, exc)
|
||||
|
||||
logger.info("JSON → SQLite migration complete")
|
||||
|
||||
|
||||
def _flat_key(filename: str) -> str:
|
||||
return "uploaded_asset_ids" if "uploaded" in filename else "rejected_asset_ids"
|
||||
def _migrate_json_data(conn: sqlite3.Connection, data: dict, status: str) -> None:
|
||||
"""Insert one JSON tracker file's data into SQLite tables."""
|
||||
flat_key = "uploaded_asset_ids" if status == "uploaded" else "rejected_asset_ids"
|
||||
flat_ids: set[str] = set(data.get(flat_key, []))
|
||||
person_covered: set[str] = set()
|
||||
|
||||
for person_name, raw_entry in data.get("by_person", {}).items():
|
||||
if isinstance(raw_entry, list):
|
||||
entry: dict = {"asset_ids": raw_entry, "scores": {}, "frigate_scores": {},
|
||||
"frigate_files": {}, "crop_dims": {}}
|
||||
else:
|
||||
entry = {
|
||||
"asset_ids": raw_entry.get("asset_ids", []),
|
||||
"scores": raw_entry.get("scores", {}),
|
||||
"frigate_scores": raw_entry.get("frigate_scores", {}),
|
||||
"frigate_files": raw_entry.get("frigate_files", {}),
|
||||
"crop_dims": raw_entry.get("crop_dims", {}),
|
||||
"frigate_count": raw_entry.get("frigate_count"),
|
||||
}
|
||||
|
||||
for asset_id in entry["asset_ids"]:
|
||||
person_covered.add(asset_id)
|
||||
dims = entry.get("crop_dims", {}).get(asset_id)
|
||||
conn.execute(
|
||||
"""INSERT OR IGNORE INTO tracked_assets
|
||||
(asset_id, person_name, status, blur_score,
|
||||
crop_width, crop_height, frigate_score)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
asset_id,
|
||||
person_name,
|
||||
status,
|
||||
entry.get("scores", {}).get(asset_id),
|
||||
dims[0] if dims else None,
|
||||
dims[1] if dims else None,
|
||||
entry.get("frigate_scores", {}).get(asset_id) if status == "uploaded" else None,
|
||||
),
|
||||
)
|
||||
|
||||
if status == "uploaded":
|
||||
for ff, aid in entry.get("frigate_files", {}).items():
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO frigate_files (frigate_filename, person_name, asset_id) VALUES (?, ?, ?)",
|
||||
(ff, person_name, aid),
|
||||
)
|
||||
fc = entry.get("frigate_count")
|
||||
if fc is not None:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO person_metadata (person_name, frigate_count) VALUES (?, ?)",
|
||||
(person_name, fc),
|
||||
)
|
||||
|
||||
# Flat IDs not covered by any by_person entry → insert with NULL person
|
||||
for asset_id in flat_ids - person_covered:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO tracked_assets (asset_id, person_name, status) VALUES (?, NULL, ?)",
|
||||
(asset_id, status),
|
||||
)
|
||||
|
||||
|
||||
def _load_flat(filename: str) -> set[str]:
|
||||
return set(_load(filename).get(_flat_key(filename), []))
|
||||
|
||||
|
||||
def _get_ids(entry: list | dict) -> list[str]:
|
||||
"""Extract asset_ids from either the old list format or the new dict format."""
|
||||
if isinstance(entry, list):
|
||||
return entry
|
||||
return entry.get("asset_ids", [])
|
||||
|
||||
|
||||
def _migrate_entry(entry: list | dict) -> dict:
|
||||
"""Ensure by_person entry is in the current dict format."""
|
||||
if isinstance(entry, list):
|
||||
return {"asset_ids": sorted(entry), "scores": {}, "frigate_scores": {}, "frigate_files": {}, "crop_dims": {}}
|
||||
entry.setdefault("asset_ids", [])
|
||||
entry.setdefault("scores", {})
|
||||
entry.setdefault("frigate_scores", {})
|
||||
entry.setdefault("frigate_files", {})
|
||||
entry.setdefault("crop_dims", {})
|
||||
return entry
|
||||
|
||||
|
||||
def _mark(
|
||||
filename: str,
|
||||
asset_id: str,
|
||||
person_name: str | None,
|
||||
score: float | None = None,
|
||||
crop_dims: tuple[int, int] | None = None,
|
||||
frigate_score: float | None = None,
|
||||
) -> None:
|
||||
data = _load(filename)
|
||||
flat_key = _flat_key(filename)
|
||||
flat = set(data.get(flat_key, []))
|
||||
flat.add(asset_id)
|
||||
data[flat_key] = sorted(flat)
|
||||
if person_name:
|
||||
by_person = data.setdefault("by_person", {})
|
||||
entry = _migrate_entry(by_person.get(person_name, {}))
|
||||
ids = set(entry["asset_ids"])
|
||||
ids.add(asset_id)
|
||||
entry["asset_ids"] = sorted(ids)
|
||||
if score is not None:
|
||||
entry["scores"][asset_id] = round(score, 4)
|
||||
if crop_dims is not None:
|
||||
entry["crop_dims"][asset_id] = [crop_dims[0], crop_dims[1]]
|
||||
if frigate_score is not None:
|
||||
entry["frigate_scores"][asset_id] = round(frigate_score, 4)
|
||||
by_person[person_name] = entry
|
||||
_save(filename, data)
|
||||
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────────
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def load_uploaded_ids() -> set[str]:
|
||||
return _load_flat(UPLOAD_TRACKER_FILE)
|
||||
conn = _get_conn()
|
||||
rows = conn.execute("SELECT asset_id FROM tracked_assets WHERE status='uploaded'").fetchall()
|
||||
return {r[0] for r in rows}
|
||||
|
||||
|
||||
def load_rejected_ids() -> set[str]:
|
||||
return _load_flat(REJECT_TRACKER_FILE)
|
||||
conn = _get_conn()
|
||||
rows = conn.execute("SELECT asset_id FROM tracked_assets WHERE status='rejected'").fetchall()
|
||||
return {r[0] for r in rows}
|
||||
|
||||
|
||||
def mark_uploaded(
|
||||
@@ -150,233 +217,269 @@ def mark_uploaded(
|
||||
crop_dims: tuple[int, int] | None = None,
|
||||
frigate_score: float | None = None,
|
||||
) -> None:
|
||||
_mark(UPLOAD_TRACKER_FILE, asset_id, person_name, score=score, crop_dims=crop_dims, frigate_score=frigate_score)
|
||||
logger.debug(f"Marked {asset_id} as uploaded ({person_name})")
|
||||
conn = _get_conn()
|
||||
with conn:
|
||||
conn.execute(
|
||||
"""INSERT OR REPLACE INTO tracked_assets
|
||||
(asset_id, person_name, status, blur_score, crop_width, crop_height, frigate_score)
|
||||
VALUES (?, ?, 'uploaded', ?, ?, ?, ?)""",
|
||||
(
|
||||
asset_id,
|
||||
person_name,
|
||||
round(score, 4) if score is not None else None,
|
||||
crop_dims[0] if crop_dims else None,
|
||||
crop_dims[1] if crop_dims else None,
|
||||
round(frigate_score, 4) if frigate_score is not None else None,
|
||||
),
|
||||
)
|
||||
logger.debug("Marked %s as uploaded (%s)", asset_id, person_name)
|
||||
|
||||
|
||||
def mark_rejected(asset_id: str, person_name: str | None = None) -> None:
|
||||
_mark(REJECT_TRACKER_FILE, asset_id, person_name)
|
||||
logger.debug(f"Marked {asset_id} as rejected ({person_name})")
|
||||
|
||||
|
||||
|
||||
def record_frigate_file(person_name: str, frigate_filename: str, asset_id: str) -> None:
|
||||
"""Record the mapping from a Frigate training filename to an Immich asset ID."""
|
||||
data = _load(UPLOAD_TRACKER_FILE)
|
||||
by_person = data.setdefault("by_person", {})
|
||||
entry = _migrate_entry(by_person.get(person_name, {}))
|
||||
entry["frigate_files"][frigate_filename] = asset_id
|
||||
by_person[person_name] = entry
|
||||
_save(UPLOAD_TRACKER_FILE, data)
|
||||
logger.debug(f"Mapped Frigate file {frigate_filename} → {asset_id} ({person_name})")
|
||||
conn = _get_conn()
|
||||
with conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO tracked_assets (asset_id, person_name, status) VALUES (?, ?, 'rejected')",
|
||||
(asset_id, person_name),
|
||||
)
|
||||
logger.debug("Marked %s as rejected (%s)", asset_id, person_name)
|
||||
|
||||
|
||||
def record_frigate_files_batch(person_name: str, mappings: dict[str, str]) -> None:
|
||||
"""Record multiple Frigate filename → asset_id mappings in a single load/save."""
|
||||
"""Record multiple Frigate filename → asset_id mappings in a single transaction."""
|
||||
if not mappings:
|
||||
return
|
||||
data = _load(UPLOAD_TRACKER_FILE)
|
||||
by_person = data.setdefault("by_person", {})
|
||||
entry = _migrate_entry(by_person.get(person_name, {}))
|
||||
entry["frigate_files"].update(mappings)
|
||||
by_person[person_name] = entry
|
||||
_save(UPLOAD_TRACKER_FILE, data)
|
||||
logger.debug(f"Batch-mapped {len(mappings)} Frigate file(s) for {person_name}")
|
||||
conn = _get_conn()
|
||||
with conn:
|
||||
conn.executemany(
|
||||
"INSERT OR REPLACE INTO frigate_files (frigate_filename, person_name, asset_id) VALUES (?, ?, ?)",
|
||||
[(ff, person_name, aid) for ff, aid in mappings.items()],
|
||||
)
|
||||
logger.debug("Batch-mapped %s Frigate file(s) for %s", len(mappings), person_name)
|
||||
|
||||
|
||||
def remove_frigate_file(person_name: str, frigate_filename: str) -> None:
|
||||
"""Remove a Frigate filename from the mapping after it has been deleted.
|
||||
"""Remove a Frigate filename mapping and clear its asset's frigate_score.
|
||||
|
||||
Does NOT unmark the source asset_id — the deletion was deliberate and
|
||||
we don't want to re-upload the inferior image on the next run.
|
||||
Does NOT unmark the source asset_id — the deletion was deliberate.
|
||||
"""
|
||||
data = _load(UPLOAD_TRACKER_FILE)
|
||||
by_person = data.get("by_person", {})
|
||||
entry = _migrate_entry(by_person.get(person_name, {}))
|
||||
asset_id = entry["frigate_files"].pop(frigate_filename, None)
|
||||
if asset_id:
|
||||
entry["frigate_scores"].pop(asset_id, None)
|
||||
by_person[person_name] = entry
|
||||
_save(UPLOAD_TRACKER_FILE, data)
|
||||
logger.debug(f"Removed Frigate file mapping {frigate_filename} ({person_name})")
|
||||
conn = _get_conn()
|
||||
with conn:
|
||||
row = conn.execute(
|
||||
"SELECT asset_id FROM frigate_files WHERE frigate_filename=? AND person_name=?",
|
||||
(frigate_filename, person_name),
|
||||
).fetchone()
|
||||
conn.execute(
|
||||
"DELETE FROM frigate_files WHERE frigate_filename=? AND person_name=?",
|
||||
(frigate_filename, person_name),
|
||||
)
|
||||
if row:
|
||||
conn.execute(
|
||||
"UPDATE tracked_assets SET frigate_score=NULL WHERE asset_id=? AND person_name=?",
|
||||
(row["asset_id"], person_name),
|
||||
)
|
||||
logger.debug("Removed Frigate file mapping %s (%s)", frigate_filename, person_name)
|
||||
|
||||
|
||||
def get_tracked_frigate_file_count(person_name: str) -> int:
|
||||
"""Return the number of Frigate training files winnow has mapped for this person.
|
||||
|
||||
Used as the cap baseline so that manually-added Frigate files do not
|
||||
consume slots from winnow's managed quota.
|
||||
"""
|
||||
data = _load(UPLOAD_TRACKER_FILE)
|
||||
entry = _migrate_entry(data.get("by_person", {}).get(person_name, {}))
|
||||
return len(entry["frigate_files"])
|
||||
"""Return the number of Frigate training files winnow has mapped for this person."""
|
||||
conn = _get_conn()
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) FROM frigate_files WHERE person_name=?", (person_name,)
|
||||
).fetchone()
|
||||
return row[0]
|
||||
|
||||
|
||||
def get_tracked_frigate_filenames(person_name: str) -> set[str]:
|
||||
"""Return the set of Frigate filenames currently mapped in the tracker for a person.
|
||||
|
||||
Used as a pre-upload baseline when the Frigate GET API is unreachable at
|
||||
upload start, so reconciliation can still identify newly uploaded files.
|
||||
"""
|
||||
data = _load(UPLOAD_TRACKER_FILE)
|
||||
entry = _migrate_entry(data.get("by_person", {}).get(person_name, {}))
|
||||
return set(entry["frigate_files"].keys())
|
||||
"""Return the set of Frigate filenames currently mapped for a person."""
|
||||
conn = _get_conn()
|
||||
rows = conn.execute(
|
||||
"SELECT frigate_filename FROM frigate_files WHERE person_name=?", (person_name,)
|
||||
).fetchall()
|
||||
return {r[0] for r in rows}
|
||||
|
||||
|
||||
def has_frigate_scores(person_name: str) -> bool:
|
||||
"""Return True if any mapped file for this person has a stored Frigate recognition score."""
|
||||
data = _load(UPLOAD_TRACKER_FILE)
|
||||
entry = _migrate_entry(data.get("by_person", {}).get(person_name, {}))
|
||||
frigate_files = entry.get("frigate_files", {})
|
||||
frigate_scores = entry.get("frigate_scores", {})
|
||||
return any(asset_id in frigate_scores for asset_id in frigate_files.values())
|
||||
conn = _get_conn()
|
||||
row = conn.execute(
|
||||
"""SELECT COUNT(*) FROM frigate_files ff
|
||||
JOIN tracked_assets ta ON ta.asset_id=ff.asset_id AND ta.person_name=ff.person_name
|
||||
WHERE ff.person_name=? AND ta.frigate_score IS NOT NULL""",
|
||||
(person_name,),
|
||||
).fetchone()
|
||||
return row[0] > 0
|
||||
|
||||
|
||||
_VALID_SCORE_COLS = frozenset({"blur_score", "frigate_score"})
|
||||
|
||||
|
||||
def _pick_mapped_file(
|
||||
person_name: str, score_key: str, *, highest: bool, exclude: set[str] | None = None
|
||||
person_name: str, score_col: str, *, highest: bool, exclude: set[str] | None = None
|
||||
) -> tuple[str, str, float] | None:
|
||||
data = _load(UPLOAD_TRACKER_FILE)
|
||||
entry = _migrate_entry(data.get("by_person", {}).get(person_name, {}))
|
||||
scores = entry.get(score_key, {})
|
||||
candidates = [
|
||||
(ff, asset_id, scores[asset_id])
|
||||
for ff, asset_id in entry.get("frigate_files", {}).items()
|
||||
if (exclude is None or ff not in exclude) and asset_id in scores
|
||||
]
|
||||
if not candidates:
|
||||
return None
|
||||
return max(candidates, key=lambda x: x[2]) if highest else min(candidates, key=lambda x: x[2])
|
||||
if score_col not in _VALID_SCORE_COLS:
|
||||
raise ValueError(f"Invalid score column: {score_col!r}")
|
||||
conn = _get_conn()
|
||||
order = "DESC" if highest else "ASC"
|
||||
rows = conn.execute(
|
||||
f"""SELECT ff.frigate_filename, ff.asset_id, ta.{score_col}
|
||||
FROM frigate_files ff
|
||||
JOIN tracked_assets ta ON ta.asset_id=ff.asset_id AND ta.person_name=ff.person_name
|
||||
WHERE ff.person_name=? AND ta.{score_col} IS NOT NULL
|
||||
ORDER BY ta.{score_col} {order}""",
|
||||
(person_name,),
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
if exclude is None or row[0] not in exclude:
|
||||
return (row[0], row[1], row[2])
|
||||
return None
|
||||
|
||||
|
||||
def get_lowest_quality_mapped_file(
|
||||
person_name: str, exclude: set[str] | None = None
|
||||
) -> tuple[str, str, float] | None:
|
||||
"""Return (frigate_filename, asset_id, score) for the mapped file with the lowest
|
||||
blur score, or None if no mapped files with known scores exist.
|
||||
|
||||
Used for quality replacement when no Frigate scores are available.
|
||||
Pass `exclude` to skip files that failed to delete this run.
|
||||
"""
|
||||
return _pick_mapped_file(person_name, "scores", highest=False, exclude=exclude)
|
||||
"""Return (frigate_filename, asset_id, score) for the mapped file with the lowest blur score."""
|
||||
return _pick_mapped_file(person_name, "blur_score", highest=False, exclude=exclude)
|
||||
|
||||
|
||||
def get_most_redundant_mapped_file(
|
||||
person_name: str, exclude: set[str] | None = None
|
||||
) -> tuple[str, str, float] | None:
|
||||
"""Return (frigate_filename, asset_id, score) for the mapped file with the highest
|
||||
Frigate recognition score, or None if no mapped files with Frigate scores exist.
|
||||
|
||||
High Frigate score = the training set already covers this face condition well
|
||||
= the most redundant file and therefore the best replacement target.
|
||||
Pass `exclude` to skip files that failed to delete this run.
|
||||
"""
|
||||
return _pick_mapped_file(person_name, "frigate_scores", highest=True, exclude=exclude)
|
||||
"""Return (frigate_filename, asset_id, score) for the mapped file with the highest Frigate score."""
|
||||
return _pick_mapped_file(person_name, "frigate_score", highest=True, exclude=exclude)
|
||||
|
||||
|
||||
def get_frigate_filename_for_asset(person_name: str, asset_id: str) -> str | None:
|
||||
"""Return the Frigate training filename mapped to this asset ID, or None."""
|
||||
data = _load(UPLOAD_TRACKER_FILE)
|
||||
entry = _migrate_entry(data.get("by_person", {}).get(person_name, {}))
|
||||
for frigate_filename, aid in entry["frigate_files"].items():
|
||||
if aid == asset_id:
|
||||
return frigate_filename
|
||||
return None
|
||||
conn = _get_conn()
|
||||
row = conn.execute(
|
||||
"SELECT frigate_filename FROM frigate_files WHERE person_name=? AND asset_id=?",
|
||||
(person_name, asset_id),
|
||||
).fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
def find_by_crop_dimension(size: int) -> list[dict]:
|
||||
"""Return all tracked crops whose width or height matches `size` pixels.
|
||||
|
||||
Returns a list of dicts: {person, asset_id, width, height, blur_score, frigate_filename}.
|
||||
frigate_filename is None when the Frigate mapping was lost to a reconciliation race.
|
||||
Returns a list of dicts: {person, asset_id, width, height, blur_score, frigate_score, frigate_filename}.
|
||||
"""
|
||||
data = _load(UPLOAD_TRACKER_FILE)
|
||||
results = []
|
||||
for person_name, raw_entry in data.get("by_person", {}).items():
|
||||
entry = _migrate_entry(raw_entry)
|
||||
scores = entry.get("scores", {})
|
||||
frigate_files = entry.get("frigate_files", {})
|
||||
asset_to_frigate = {v: k for k, v in frigate_files.items()}
|
||||
frigate_scores = entry.get("frigate_scores", {})
|
||||
for asset_id, dims in entry.get("crop_dims", {}).items():
|
||||
w, h = dims[0], dims[1]
|
||||
if w == size or h == size:
|
||||
results.append({
|
||||
"person": person_name,
|
||||
"asset_id": asset_id,
|
||||
"width": w,
|
||||
"height": h,
|
||||
"blur_score": scores.get(asset_id),
|
||||
"frigate_score": frigate_scores.get(asset_id),
|
||||
"frigate_filename": asset_to_frigate.get(asset_id),
|
||||
})
|
||||
return results
|
||||
conn = _get_conn()
|
||||
rows = conn.execute(
|
||||
"""SELECT ta.person_name, ta.asset_id, ta.crop_width, ta.crop_height,
|
||||
ta.blur_score, ta.frigate_score, ff.frigate_filename
|
||||
FROM tracked_assets ta
|
||||
LEFT JOIN frigate_files ff ON ff.asset_id=ta.asset_id AND ff.person_name=ta.person_name
|
||||
WHERE ta.status='uploaded' AND (ta.crop_width=? OR ta.crop_height=?)""",
|
||||
(size, size),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"person": r["person_name"],
|
||||
"asset_id": r["asset_id"],
|
||||
"width": r["crop_width"],
|
||||
"height": r["crop_height"],
|
||||
"blur_score": r["blur_score"],
|
||||
"frigate_score": r["frigate_score"],
|
||||
"frigate_filename": r["frigate_filename"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def update_frigate_count(person_name: str, count: int) -> None:
|
||||
"""Record Frigate's authoritative training image count for a person."""
|
||||
data = _load(UPLOAD_TRACKER_FILE)
|
||||
by_person = data.setdefault("by_person", {})
|
||||
entry = _migrate_entry(by_person.get(person_name, {}))
|
||||
entry["frigate_count"] = count
|
||||
by_person[person_name] = entry
|
||||
_save(UPLOAD_TRACKER_FILE, data)
|
||||
conn = _get_conn()
|
||||
with conn:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO person_metadata (person_name, frigate_count) VALUES (?, ?)",
|
||||
(person_name, count),
|
||||
)
|
||||
|
||||
|
||||
def reset_person(person_name: str) -> None:
|
||||
"""Remove all uploaded and rejected records for a given person.
|
||||
|
||||
Also deletes winnow-managed Frigate training files so the next run starts
|
||||
clean rather than uploading on top of orphaned files. Manually-added Frigate
|
||||
files (not in frigate_files) are never touched. Proceeds with tracker reset
|
||||
even if Frigate is unreachable.
|
||||
clean rather than uploading on top of orphaned files.
|
||||
"""
|
||||
upload_data = _load(UPLOAD_TRACKER_FILE)
|
||||
entry = _migrate_entry(upload_data.get("by_person", {}).get(person_name, {}))
|
||||
frigate_filenames = list(entry.get("frigate_files", {}).keys())
|
||||
conn = _get_conn()
|
||||
|
||||
# Collect Frigate filenames before deleting
|
||||
frigate_filenames = list(get_tracked_frigate_filenames(person_name))
|
||||
if frigate_filenames:
|
||||
if not os.environ.get("FRIGATE_URL", "").strip():
|
||||
logger.info(f"FRIGATE_URL not set — skipping Frigate file deletion for {person_name}")
|
||||
logger.info("FRIGATE_URL not set — skipping Frigate file deletion for %s", person_name)
|
||||
elif delete_frigate_person_files(person_name, frigate_filenames):
|
||||
logger.info(f"Deleted {len(frigate_filenames)} Frigate file(s) for {person_name}")
|
||||
logger.info("Deleted %s Frigate file(s) for %s", len(frigate_filenames), person_name)
|
||||
else:
|
||||
logger.warning(f"Could not delete Frigate files for {person_name} — tracker reset proceeding anyway")
|
||||
logger.warning(
|
||||
"Could not delete Frigate files for %s — tracker reset proceeding anyway", person_name
|
||||
)
|
||||
|
||||
changed = False
|
||||
tracker_files = ((UPLOAD_TRACKER_FILE, upload_data), (REJECT_TRACKER_FILE, _load(REJECT_TRACKER_FILE)))
|
||||
for filename, data in tracker_files:
|
||||
flat_key = _flat_key(filename)
|
||||
by_person = data.get("by_person", {})
|
||||
tracker_entry = by_person.pop(person_name, None)
|
||||
if tracker_entry is not None:
|
||||
person_ids = set(_get_ids(tracker_entry))
|
||||
flat = set(data.get(flat_key, [])) - person_ids
|
||||
data[flat_key] = sorted(flat)
|
||||
data["by_person"] = by_person
|
||||
_save(filename, data)
|
||||
changed = True
|
||||
if changed:
|
||||
logger.info(f"Reset tracking data for {person_name}")
|
||||
else:
|
||||
logger.debug(f"reset_person: no tracking data found for {person_name}")
|
||||
with conn:
|
||||
conn.execute("DELETE FROM frigate_files WHERE person_name=?", (person_name,))
|
||||
conn.execute("DELETE FROM tracked_assets WHERE person_name=?", (person_name,))
|
||||
conn.execute("DELETE FROM person_metadata WHERE person_name=?", (person_name,))
|
||||
|
||||
logger.info("Reset tracking data for %s", person_name)
|
||||
|
||||
|
||||
def get_person_summary() -> dict[str, dict]:
|
||||
"""Return {person_name: {uploaded, rejected, frigate_count, scores, frigate_files}} for display/capacity."""
|
||||
uploaded_data = _load(UPLOAD_TRACKER_FILE).get("by_person", {})
|
||||
rejected_data = _load(REJECT_TRACKER_FILE).get("by_person", {})
|
||||
names = set(uploaded_data) | set(rejected_data)
|
||||
result = {}
|
||||
for name in sorted(names):
|
||||
u_entry = uploaded_data.get(name, {})
|
||||
r_entry = rejected_data.get(name, {})
|
||||
result[name] = {
|
||||
"uploaded": len(_get_ids(u_entry)),
|
||||
"rejected": len(_get_ids(r_entry)),
|
||||
"frigate_count": u_entry.get("frigate_count") if isinstance(u_entry, dict) else None,
|
||||
"scores": u_entry.get("scores", {}) if isinstance(u_entry, dict) else {},
|
||||
"frigate_files": u_entry.get("frigate_files", {}) if isinstance(u_entry, dict) else {},
|
||||
}
|
||||
return result
|
||||
conn = _get_conn()
|
||||
|
||||
# Counts per person per status
|
||||
rows = conn.execute(
|
||||
"""SELECT person_name, status, COUNT(*) AS cnt
|
||||
FROM tracked_assets WHERE person_name IS NOT NULL
|
||||
GROUP BY person_name, status"""
|
||||
).fetchall()
|
||||
|
||||
summary: dict[str, dict] = {}
|
||||
for r in rows:
|
||||
name = r["person_name"]
|
||||
if name not in summary:
|
||||
summary[name] = {"uploaded": 0, "rejected": 0, "frigate_count": None,
|
||||
"scores": {}, "frigate_files": {}}
|
||||
summary[name][r["status"]] = r["cnt"]
|
||||
|
||||
# Scores for uploaded assets
|
||||
score_rows = conn.execute(
|
||||
"""SELECT person_name, asset_id, blur_score
|
||||
FROM tracked_assets
|
||||
WHERE status='uploaded' AND person_name IS NOT NULL AND blur_score IS NOT NULL"""
|
||||
).fetchall()
|
||||
for r in score_rows:
|
||||
name = r["person_name"]
|
||||
if name not in summary:
|
||||
summary[name] = {"uploaded": 0, "rejected": 0, "frigate_count": None,
|
||||
"scores": {}, "frigate_files": {}}
|
||||
summary[name]["scores"][r["asset_id"]] = r["blur_score"]
|
||||
|
||||
# Frigate file mappings
|
||||
ff_rows = conn.execute(
|
||||
"SELECT person_name, frigate_filename, asset_id FROM frigate_files"
|
||||
).fetchall()
|
||||
for r in ff_rows:
|
||||
name = r["person_name"]
|
||||
if name not in summary:
|
||||
summary[name] = {"uploaded": 0, "rejected": 0, "frigate_count": None,
|
||||
"scores": {}, "frigate_files": {}}
|
||||
summary[name]["frigate_files"][r["frigate_filename"]] = r["asset_id"]
|
||||
|
||||
# Frigate counts
|
||||
meta_rows = conn.execute(
|
||||
"SELECT person_name, frigate_count FROM person_metadata"
|
||||
).fetchall()
|
||||
for r in meta_rows:
|
||||
name = r["person_name"]
|
||||
if name not in summary:
|
||||
summary[name] = {"uploaded": 0, "rejected": 0, "frigate_count": None,
|
||||
"scores": {}, "frigate_files": {}}
|
||||
summary[name]["frigate_count"] = r["frigate_count"]
|
||||
|
||||
return dict(sorted(summary.items()))
|
||||
|
||||
|
||||
def filter_already_uploaded(
|
||||
@@ -390,5 +493,5 @@ def filter_already_uploaded(
|
||||
new_ids = [aid for aid in asset_ids if aid not in exclude]
|
||||
skipped = len(asset_ids) - len(new_ids)
|
||||
if skipped:
|
||||
logger.info(f"Skipping {skipped} assets already uploaded or rejected by Frigate")
|
||||
logger.info("Skipping %s assets already uploaded or rejected by Frigate", skipped)
|
||||
return new_ids
|
||||
|
||||
Reference in New Issue
Block a user