From 3cff5045b2883ebc7f4210b1fbfdfb451dda8d29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=A1=E5=8F=A3=E6=B5=AA=E4=BA=BA?= Date: Sun, 18 Oct 2026 14:59:35 +0800 Subject: [PATCH] feat: add minimum photo resolution filters for sources --- docs/openapi.json | 28 +++++ pigway_frame_flow/providers/photo_sources.py | 22 ++++ pigway_frame_flow/web/extensions.js | 2 +- pigway_frame_flow/web/features.js | 2 +- tests/schema-contract.json | 28 +++++ tests/test_photo_resolution.py | 123 +++++++++++++++++++ 6 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 tests/test_photo_resolution.py diff --git a/docs/openapi.json b/docs/openapi.json index de7fcad..0524db4 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -16394,6 +16394,20 @@ ], "title": "Orientation" }, + "minimum_short_edge": { + "type": "integer", + "maximum": 50000.0, + "minimum": 0.0, + "title": "Minimum Short Edge", + "default": 0 + }, + "minimum_long_edge": { + "type": "integer", + "maximum": 50000.0, + "minimum": 0.0, + "title": "Minimum Long Edge", + "default": 0 + }, "minimum_sharpness": { "type": "number", "maximum": 10000.0, @@ -16547,6 +16561,20 @@ ], "title": "Orientation" }, + "minimum_short_edge": { + "type": "integer", + "maximum": 50000.0, + "minimum": 0.0, + "title": "Minimum Short Edge", + "default": 0 + }, + "minimum_long_edge": { + "type": "integer", + "maximum": 50000.0, + "minimum": 0.0, + "title": "Minimum Long Edge", + "default": 0 + }, "minimum_sharpness": { "type": "number", "maximum": 10000.0, diff --git a/pigway_frame_flow/providers/photo_sources.py b/pigway_frame_flow/providers/photo_sources.py index 5fa5c32..176a68f 100644 --- a/pigway_frame_flow/providers/photo_sources.py +++ b/pigway_frame_flow/providers/photo_sources.py @@ -39,6 +39,8 @@ class PhotoSourceInput(StrictModel): organized_only: bool = False folder_caption: bool = False orientation: Literal["auto", "landscape", "portrait"] | None = None + minimum_short_edge: int = Field(default=0, ge=0, le=50000) + minimum_long_edge: int = Field(default=0, ge=0, le=50000) minimum_sharpness: float = Field(default=0, ge=0, le=10000) minimum_brightness: float = Field(default=0, ge=0, le=255) maximum_brightness: float = Field(default=255, ge=0, le=255) @@ -412,6 +414,8 @@ class PhotoSources: height, ) ) + if not self.resolution_matches(source, width, height): + continue direction = orientation(width, height) if wanted and direction != wanted: continue @@ -478,6 +482,8 @@ class PhotoSources: rotation = row.get("additional", {}).get("orientation", 1) if rotation in (5, 6, 7, 8): width, height = height, width + if not self.resolution_matches(source, width, height): + continue direction = orientation(width, height) if wanted and (not width or not height or direction != wanted): continue @@ -560,6 +566,11 @@ class PhotoSources: self.cache[source_id] = (time.monotonic(), items, wanted, session_identity) return items + @staticmethod + def resolution_matches(source, width, height): + short, long = sorted((width, height)) + return short >= source.minimum_short_edge and long >= source.minimum_long_edge + def read(self, source_id, item_id=None, *, target_size=(1200, 1600)): source = self.get(source_id) items = self.catalog(source_id, target_size=target_size) @@ -642,4 +653,15 @@ class PhotoSources: data = self.assets.fetch_url(item["id"], headers=self.headers(source)) if len(data) > self.settings.max_bytes: raise FlowError(E.INVALID_REQUEST, "Image exceeds size limit") + if source.minimum_short_edge or source.minimum_long_edge: + from io import BytesIO + + from PIL import Image + + with Image.open(BytesIO(data)) as image: + if not self.resolution_matches(source, *image.size): + raise FlowError( + E.INVALID_REQUEST, + "Photo is below the configured minimum resolution", + ) return data, item["name"], item.get("caption") diff --git a/pigway_frame_flow/web/extensions.js b/pigway_frame_flow/web/extensions.js index 9167fb5..33694d4 100644 --- a/pigway_frame_flow/web/extensions.js +++ b/pigway_frame_flow/web/extensions.js @@ -49,7 +49,7 @@ async function officialWidgetControls(info,data){ const send=cover_id=>confirmDialog(tr('此操作会改变官方订阅和画框内容,是否继续?','This changes the official subscription and frame content. Continue?'),async()=>{const args=cover_id?Object.fromEntries([...document.querySelectorAll('[data-widget-arg]')].filter(x=>x.value).map(x=>[x.dataset.widgetArg,x.value])):{};const r=await api('/api/v1/devices/'+state.device+'/widgets/selection',{method:'POST',body:{cover_id,args},headers:{'Idempotency-Key':'widget-'+clientUUID()}});await waitOperation(r.operation_id);await navigate('templates');});if(!disabled){$('#official-widget-use').onclick=busy(()=>send(Number($('#official-widget-choice').value)));$('#official-widget-cancel').onclick=busy(()=>send(null));} } function addPhotoQualityFields(source){ - const box=document.createElement('section');box.innerHTML=`

${tr('照片筛选','Photo filtering')}

${tr('对 API 获取的预览图按清晰度、亮度和相似度筛选,不修改或删除 NAS 原照片;无法保证识别闭眼。建议先使用较低阈值。','Filters API previews by sharpness, brightness and similarity without modifying or deleting NAS originals. Closed-eye detection is not guaranteed. Start with low thresholds.')}

`;$('#photo-source-form .modal-actions').before(box);$('#source-people-space').value=source?.people_space||'shared'; + const box=document.createElement('section');box.innerHTML=`

${tr('照片筛选','Photo filtering')}

${tr('对 API 获取的预览图按清晰度、亮度和相似度筛选,不修改或删除 NAS 原照片;无法保证识别闭眼。建议先使用较低阈值。','Filters API previews by sharpness, brightness and similarity without modifying or deleting NAS originals. Closed-eye detection is not guaranteed. Start with low thresholds.')}

${[['short','minimum_short_edge',tr('最小短边(像素)','Minimum short edge (px)')],['long','minimum_long_edge',tr('最小长边(像素)','Minimum long edge (px)')]].map(([id,key,label])=>`
`).join('')}

${tr('0 表示不限制;按源照片和实际取得的预览图像素筛选,横竖图通用。可从短边 1200、长边 1600 开始设置;分辨率不代表对焦清晰度。','0 disables. Checks source and downloaded preview dimensions regardless of orientation. Start with short edge 1200 and long edge 1600; pixel count does not measure focus.')}

`;$('#photo-source-form .modal-actions').before(box);$('#source-people-space').value=source?.people_space||'shared'; } async function aiImagePanel(){ diff --git a/pigway_frame_flow/web/features.js b/pigway_frame_flow/web/features.js index 4690ff6..25e5e52 100644 --- a/pigway_frame_flow/web/features.js +++ b/pigway_frame_flow/web/features.js @@ -152,7 +152,7 @@ async function photoSourcesPanel(){ async function photoSourceForm(source=null){ showModal(`

${t(source?'edit':'addSource')}

${t('sourceIndependentHelp')}

${t('sourceLocationHint')}

${t('sourceCredentialHint')}

${t('orientationHint')}

${t('folderCaptionHint')}

${t('peopleHint')}

${t('peopleSelected')}${(source?.person_ids||[]).length}

`); $('#source-orientation').value=source?(source.orientation||''):'auto';const selectedPeople=new Set(source?.person_ids||[]);$('#source-person-match').value=source?.person_match||'any';$('#source-people-load').onclick=busy(async()=>{await photosPeoplePicker(selectedPeople);});$('#source-kind').value=source?.kind||'synology-photos';const update=()=>{const photosAPI=$('#source-kind').value==='synology-photos';$('#source-location').labels[0].textContent=photosAPI?tr('照片目录','Photo folder'):t('sourceLocation');$('#source-location').nextElementSibling.textContent=photosAPI?tr('填写所选空间内的目录路径,. 表示整个空间。按需获取 XL 预览图,不下载原图。','Folder path within the selected space; . means the whole space. Downloads XL previews on demand, not originals.'):t('sourceLocationHint');const dav=!['local','synology-photos'].includes($('#source-kind').value);$('#source-orientation').disabled=dav;$('#source-credential').disabled=!dav;$('#source-credential').closest('.field').hidden=$('#source-kind').value!=='webdav';const oauth=$('#source-oauth');if(oauth)oauth.closest('.field').hidden=!['google-drive','alipan','baidu'].includes($('#source-kind').value);const quality=$('#source-local-quality');if(quality)quality.hidden=dav;$('#source-recursive').disabled=$('#source-kind').value==='webdav';$('#source-synology-previews').disabled=$('#source-kind').value!=='local';$('#source-organized').disabled=dav;$('#source-caption').disabled=dav;$('#source-people-filter').disabled=dav;$('#source-people-load').disabled=dav;};addPhotoQualityFields(source);$('#source-location').closest('.field').before($('#source-people-space').closest('.field'));update();$('#source-kind').onchange=update;$('#source-people-space').onchange=()=>{selectedPeople.clear();$('#source-people-panel').innerHTML='';}; - $('#photo-source-form').onsubmit=busy(async e=>{e.preventDefault();const oauth=$('#source-oauth').value;const sourceId=$('#source-id').value;const native=['google-drive','alipan','baidu'].includes($('#source-kind').value);if(oauth&&native)JSON.parse(oauth);const dav=!['local','synology-photos'].includes($('#source-kind').value);await api('/api/v1/photo-sources/'+$('#source-id').value,{method:'PUT',body:{name:$('#source-name').value,kind:$('#source-kind').value,location:$('#source-location').value,recursive:$('#source-kind').value!=='webdav'&&$('#source-recursive').checked,synology_previews:false,catalog_limit:$('#source-organized').checked?100000:(source?.catalog_limit||10000),organized_only:!dav&&$('#source-organized').checked,folder_caption:!dav&&$('#source-caption').checked,orientation:dav?null:($('#source-orientation').value||null),people_filter:!dav&&$('#source-people-filter').checked,person_ids:[...selectedPeople],person_match:$('#source-person-match').value,people_space:$('#source-people-space').value,minimum_sharpness:Number($('#source-sharpness').value),minimum_brightness:Number($('#source-bright-min').value),maximum_brightness:Number($('#source-bright-max').value),avoid_duplicates:$('#source-dedup').checked,enabled:$('#source-enabled').checked,credential_env:dav?($('#source-credential').value||null):null}});if(oauth&&native){$('#source-oauth').value='';await api('/api/v1/photo-sources/'+sourceId+'/credentials',{method:'PUT',body:{key:oauth}});}$('#modal').close();await navigate('photo-sources');}); + $('#photo-source-form').onsubmit=busy(async e=>{e.preventDefault();const oauth=$('#source-oauth').value;const sourceId=$('#source-id').value;const native=['google-drive','alipan','baidu'].includes($('#source-kind').value);if(oauth&&native)JSON.parse(oauth);const dav=!['local','synology-photos'].includes($('#source-kind').value);await api('/api/v1/photo-sources/'+$('#source-id').value,{method:'PUT',body:{name:$('#source-name').value,kind:$('#source-kind').value,location:$('#source-location').value,recursive:$('#source-kind').value!=='webdav'&&$('#source-recursive').checked,synology_previews:false,catalog_limit:$('#source-organized').checked?100000:(source?.catalog_limit||10000),organized_only:!dav&&$('#source-organized').checked,folder_caption:!dav&&$('#source-caption').checked,orientation:dav?null:($('#source-orientation').value||null),people_filter:!dav&&$('#source-people-filter').checked,person_ids:[...selectedPeople],person_match:$('#source-person-match').value,people_space:$('#source-people-space').value,minimum_short_edge:Number($('#source-min-short').value),minimum_long_edge:Number($('#source-min-long').value),minimum_sharpness:Number($('#source-sharpness').value),minimum_brightness:Number($('#source-bright-min').value),maximum_brightness:Number($('#source-bright-max').value),avoid_duplicates:$('#source-dedup').checked,enabled:$('#source-enabled').checked,credential_env:dav?($('#source-credential').value||null):null}});if(oauth&&native){$('#source-oauth').value='';await api('/api/v1/photo-sources/'+sourceId+'/credentials',{method:'PUT',body:{key:oauth}});}$('#modal').close();await navigate('photo-sources');}); } async function browseSource(id,offset=0,refresh=false){ const result=await api('/api/v1/photo-sources/'+id+'/images?device_id='+encodeURIComponent(state.device)+'&offset='+offset+'&limit=50&refresh='+refresh);const box=$('#source-browser');if(!box)return; diff --git a/tests/schema-contract.json b/tests/schema-contract.json index 338d8ce..7020848 100644 --- a/tests/schema-contract.json +++ b/tests/schema-contract.json @@ -931,6 +931,20 @@ ], "title": "Orientation" }, + "minimum_short_edge": { + "type": "integer", + "maximum": 50000.0, + "minimum": 0.0, + "title": "Minimum Short Edge", + "default": 0 + }, + "minimum_long_edge": { + "type": "integer", + "maximum": 50000.0, + "minimum": 0.0, + "title": "Minimum Long Edge", + "default": 0 + }, "minimum_sharpness": { "type": "number", "maximum": 10000.0, @@ -1084,6 +1098,20 @@ ], "title": "Orientation" }, + "minimum_short_edge": { + "type": "integer", + "maximum": 50000.0, + "minimum": 0.0, + "title": "Minimum Short Edge", + "default": 0 + }, + "minimum_long_edge": { + "type": "integer", + "maximum": 50000.0, + "minimum": 0.0, + "title": "Minimum Long Edge", + "default": 0 + }, "minimum_sharpness": { "type": "number", "maximum": 10000.0, diff --git a/tests/test_photo_resolution.py b/tests/test_photo_resolution.py new file mode 100644 index 0000000..d3190e5 --- /dev/null +++ b/tests/test_photo_resolution.py @@ -0,0 +1,123 @@ +import io + +import pytest +from PIL import Image + +from pigway_frame_flow.errors import FlowError +from pigway_frame_flow.providers.photo_sources import PhotoSourceInput + + +def test_minimum_resolution_filters_catalog_and_persists( + client, service, settings, legacy_photo_engine +): + root = settings.data_dir.parent / "photos" + root.mkdir() + settings.photo_root = root + for name, size in [ + ("small.jpg", (160, 120)), + ("wide.jpg", (800, 600)), + ("tall.jpg", (600, 800)), + ]: + Image.new("RGB", size).save(root / name) + response = client.put( + "/api/v1/photo-sources/family", + json={ + "name": "Family", + "kind": "local", + "location": ".", + "minimum_short_edge": 600, + "minimum_long_edge": 800, + }, + ) + assert response.status_code == 200 + assert response.json()["minimum_short_edge"] == 600 + assert service.photo_sources.get("family").minimum_long_edge == 800 + assert {x["name"] for x in service.photo_sources.catalog("family")} == { + "wide.jpg", + "tall.jpg", + } + response = client.put( + "/api/v1/photo-sources/family", + json={ + "name": "Family", + "kind": "local", + "location": ".", + }, + ) + assert response.status_code == 200 + assert len(service.photo_sources.catalog("family")) == 3 + + +def test_resolution_rejects_small_synology_preview(service, monkeypatch): + photos = service.photo_sources + photos.save( + "family", + PhotoSourceInput( + name="Family", + kind="synology-photos", + location=".", + minimum_short_edge=600, + minimum_long_edge=800, + ), + ) + row = {"id": "1", "name": "photo.jpg"} + monkeypatch.setattr(photos, "catalog", lambda *args, **kwargs: [row]) + photos.api_items["family"] = {"1": {}} + monkeypatch.setattr(photos.synology, "item_people", lambda *a, **kw: []) + + def image(size): + out = io.BytesIO() + Image.new("RGB", size).save(out, "JPEG") + return out.getvalue() + + monkeypatch.setattr(photos.synology, "download", lambda *a, **kw: image((300, 400))) + with pytest.raises(FlowError, match="minimum resolution"): + photos.read("family", "1") + monkeypatch.setattr(photos.synology, "download", lambda *a, **kw: image((800, 600))) + assert photos.read("family", "1")[1] == "photo.jpg" + + +def test_minimum_resolution_rejects_negative_value(client): + response = client.put( + "/api/v1/photo-sources/family", + json={ + "name": "Family", + "kind": "synology-photos", + "location": ".", + "minimum_short_edge": -1, + }, + ) + assert response.status_code == 422 + + +def test_synology_metadata_resolution_filters_before_catalog_limit( + client, service, monkeypatch +): + photos = service.photo_sources + monkeypatch.setattr(photos.synology, "sid", lambda: "fixture") + rows = [ + { + "id": i, + "filename": f"{i}.jpg", + "additional": { + "folder": "/family", + "resolution": {"width": w, "height": h}, + }, + } + for i, w, h in [(1, 300, 400), (2, 800, 600), (3, 0, 0)] + ] + monkeypatch.setattr(photos.synology, "items", lambda *a, **kw: rows) + response = client.put( + "/api/v1/photo-sources/family", + json={ + "name": "Family", + "kind": "synology-photos", + "location": ".", + "minimum_short_edge": 600, + "minimum_long_edge": 800, + "catalog_limit": 1, + }, + ) + assert response.status_code == 200 + assert response.json()["minimum_long_edge"] == 800 + assert [x["id"] for x in photos.catalog("family")] == ["2"]