From 2876c179ac173c189f9114f0916d951f650ed031 Mon Sep 17 00:00:00 2001 From: wh Date: Fri, 10 Apr 2026 15:40:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(US2):=20image=20quad=20extraction=20?= =?UTF-8?q?=E2=80=94=20POST=20/api/v1/image/extract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app/models/image_models.py: BBox, QuadrupleItem, ImageExtract{Request,Response} - app/services/image_service.py: download → base64 LLM → bbox clamp → crop upload - app/routers/image.py: POST /image/extract handler - tests: 4 service + 3 router tests, 7/7 passing --- .../__pycache__/image_models.cpython-312.pyc | Bin 0 -> 1375 bytes app/models/image_models.py | 28 +++++ app/routers/__pycache__/image.cpython-312.pyc | Bin 252 -> 1133 bytes app/routers/image.py | 17 ++- .../__pycache__/image_service.cpython-312.pyc | Bin 0 -> 4127 bytes app/services/image_service.py | 90 ++++++++++++++++ ..._image_router.cpython-312-pytest-9.0.3.pyc | Bin 0 -> 6900 bytes ...image_service.cpython-312-pytest-9.0.3.pyc | Bin 0 -> 10412 bytes tests/test_image_router.py | 63 +++++++++++ tests/test_image_service.py | 102 ++++++++++++++++++ 10 files changed, 299 insertions(+), 1 deletion(-) create mode 100644 app/models/__pycache__/image_models.cpython-312.pyc create mode 100644 app/models/image_models.py create mode 100644 app/services/__pycache__/image_service.cpython-312.pyc create mode 100644 app/services/image_service.py create mode 100644 tests/__pycache__/test_image_router.cpython-312-pytest-9.0.3.pyc create mode 100644 tests/__pycache__/test_image_service.cpython-312-pytest-9.0.3.pyc create mode 100644 tests/test_image_router.py create mode 100644 tests/test_image_service.py diff --git a/app/models/__pycache__/image_models.cpython-312.pyc b/app/models/__pycache__/image_models.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..35c1d1e051e9c2b5a022e96fa8afe268ba70b483 GIT binary patch literal 1375 zcma)+&rcIU6vt<}v)j^Asz4N_0m1=Y4_-7z2=PD!18R-GE)B_K=?t>6?JhH0p&SfJ zHGx|_!Czov{8Ky`I5d;!#gjJ^>cx|9W`RJ1iJSJjZ@#npcHU>+?w5Q%i{NsB5A;T;(7B96+Kp>paa;ua?iPlIcs)JbHW zL_|jo+jSI~nF%)(hF?SBb%j?-uB|eZ*1RgGRAnixhO8dwxR=tkx)&VHSbi;51xmeI zEsVX`4{MUrPv}rD?$5pZQ;X}NSe21i;fsN{#Dhes=TphW8y{>hiWVU`59Fd>^s-t*SkEs5eu&pKjUk4E&dFn$%TS_YBLBy5>f_s1@~0%ape6c6l)? z=EbeJv<)}mC9Nbz3L@BvRPb7K6$jT^DU!N&aJPlOr(=@%TGuM{C>G=?T_K@E;mHc|ORnpc$`Zme!e#*|^g-o4lO80UyXjNA>|Z9cO~QQM z^S_fd>NQ5h9aRA?F+y)t%Y^F^27b-AE9_QsM21I5Pz?M@N`l;yS9gfZRZ3>L3O(^i z*a$Z$@td?(R>OCTLx;TtyE6siJi;(mKO`QZYazK1`m+|$0$xy7!x~F4^xL36QkK>G zx{jvND0-;9Qs;0EU(6{q26;RYc3GNj+CZjCQ{biDLZm@3QEPB;aA&M=qBvKl*IC0Z zlcH;1C2k-~c6FDCqTOf|sT>b0N)Gak`M)cgbfkymEk%ic}IRyq*ksHMkAthQ~>$~Ku(V3mZe!WO#l5gq+(W>)%F%=hM@MWbyXCHf$HC)-L4wDM;cv+H^Tqh|l1>!l~5AR>TRE005g1E{^~J diff --git a/app/routers/image.py b/app/routers/image.py index 30aefbc..7ede9f4 100644 --- a/app/routers/image.py +++ b/app/routers/image.py @@ -1,3 +1,18 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Depends + +from app.clients.llm.base import LLMClient +from app.clients.storage.base import StorageClient +from app.core.dependencies import get_llm_client, get_storage_client +from app.models.image_models import ImageExtractRequest, ImageExtractResponse +from app.services import image_service router = APIRouter(tags=["Image"]) + + +@router.post("/image/extract", response_model=ImageExtractResponse) +async def extract_image( + req: ImageExtractRequest, + llm: LLMClient = Depends(get_llm_client), + storage: StorageClient = Depends(get_storage_client), +) -> ImageExtractResponse: + return await image_service.extract_quads(req, llm, storage) diff --git a/app/services/__pycache__/image_service.cpython-312.pyc b/app/services/__pycache__/image_service.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..334c01159291d44b3b8be06549d5f1149d846ea6 GIT binary patch literal 4127 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 ImageExtractResponse: + cfg = get_config() + bucket = cfg["storage"]["buckets"]["source_data"] + model = req.model or cfg["models"]["default_vision"] + + image_bytes = await storage.download_bytes(bucket, req.file_path) + + # Decode with OpenCV for cropping; encode as base64 for LLM + nparr = np.frombuffer(image_bytes, np.uint8) + img = cv2.imdecode(nparr, cv2.IMREAD_COLOR) + img_h, img_w = img.shape[:2] + + b64 = base64.b64encode(image_bytes).decode() + image_data_url = f"data:image/jpeg;base64,{b64}" + + prompt = req.prompt_template or _DEFAULT_PROMPT + messages = [ + { + "role": "user", + "content": [ + {"type": "image_url", "image_url": {"url": image_data_url}}, + {"type": "text", "text": prompt}, + ], + } + ] + + raw = await llm.chat_vision(model, messages) + logger.info("image_extract", extra={"file": req.file_path, "model": model}) + + items_raw = extract_json(raw) + items: list[QuadrupleItem] = [] + + for idx, item in enumerate(items_raw): + b = item["bbox"] + # Clamp bbox to image dimensions + x = max(0, min(int(b["x"]), img_w - 1)) + y = max(0, min(int(b["y"]), img_h - 1)) + w = min(int(b["w"]), img_w - x) + h = min(int(b["h"]), img_h - y) + + crop = img[y : y + h, x : x + w] + _, crop_buf = cv2.imencode(".jpg", crop) + crop_bytes = crop_buf.tobytes() + + crop_path = f"crops/{req.task_id}/{idx}.jpg" + await storage.upload_bytes(bucket, crop_path, crop_bytes, "image/jpeg") + + items.append( + QuadrupleItem( + subject=item["subject"], + predicate=item["predicate"], + object=item["object"], + qualifier=item.get("qualifier"), + bbox=BBox(x=x, y=y, w=w, h=h), + cropped_image_path=crop_path, + ) + ) + + return ImageExtractResponse(items=items) diff --git a/tests/__pycache__/test_image_router.cpython-312-pytest-9.0.3.pyc b/tests/__pycache__/test_image_router.cpython-312-pytest-9.0.3.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a38db64a455347a5bc3e4b8bc023c686393247cc GIT binary patch literal 6900 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 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_image_service.cpython-312-pytest-9.0.3.pyc b/tests/__pycache__/test_image_service.cpython-312-pytest-9.0.3.pyc new file mode 100644 index 0000000000000000000000000000000000000000..161aaaf82e6f6e7f20fff56f825559dd58933696 GIT binary patch literal 10412 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 literal 0 HcmV?d00001 diff --git a/tests/test_image_router.py b/tests/test_image_router.py new file mode 100644 index 0000000..e98ce31 --- /dev/null +++ b/tests/test_image_router.py @@ -0,0 +1,63 @@ +import json +import numpy as np +import cv2 +import pytest +from unittest.mock import AsyncMock + +from app.core.exceptions import StorageError + + +def _make_test_image_bytes() -> bytes: + img = np.zeros((80, 100, 3), dtype=np.uint8) + _, buf = cv2.imencode(".jpg", img) + return buf.tobytes() + + +SAMPLE_QUADS_JSON = json.dumps([ + { + "subject": "电缆接头", + "predicate": "位于", + "object": "配电箱左侧", + "qualifier": "2024年检修", + "bbox": {"x": 5, "y": 5, "w": 20, "h": 15}, + } +]) + + +def test_image_extract_returns_200(client, mock_llm, mock_storage): + mock_storage.download_bytes = AsyncMock(return_value=_make_test_image_bytes()) + mock_llm.chat_vision = AsyncMock(return_value=SAMPLE_QUADS_JSON) + mock_storage.upload_bytes = AsyncMock(return_value=None) + + resp = client.post( + "/api/v1/image/extract", + json={"file_path": "image/test.jpg", "task_id": 1}, + ) + assert resp.status_code == 200 + data = resp.json() + assert "items" in data + assert data["items"][0]["subject"] == "电缆接头" + assert data["items"][0]["cropped_image_path"] == "crops/1/0.jpg" + + +def test_image_extract_llm_parse_error_returns_502(client, mock_llm, mock_storage): + mock_storage.download_bytes = AsyncMock(return_value=_make_test_image_bytes()) + mock_llm.chat_vision = AsyncMock(return_value="not json {{") + + resp = client.post( + "/api/v1/image/extract", + json={"file_path": "image/test.jpg", "task_id": 1}, + ) + assert resp.status_code == 502 + assert resp.json()["code"] == "LLM_PARSE_ERROR" + + +def test_image_extract_storage_error_returns_502(client, mock_storage): + mock_storage.download_bytes = AsyncMock(side_effect=StorageError("RustFS down")) + + resp = client.post( + "/api/v1/image/extract", + json={"file_path": "image/test.jpg", "task_id": 1}, + ) + assert resp.status_code == 502 + assert resp.json()["code"] == "STORAGE_ERROR" diff --git a/tests/test_image_service.py b/tests/test_image_service.py new file mode 100644 index 0000000..ee6e8ae --- /dev/null +++ b/tests/test_image_service.py @@ -0,0 +1,102 @@ +import io +import json +import pytest +import numpy as np +import cv2 +from unittest.mock import AsyncMock + +from app.core.exceptions import LLMParseError +from app.models.image_models import ImageExtractRequest + + +def _make_test_image_bytes(width=100, height=80) -> bytes: + img = np.zeros((height, width, 3), dtype=np.uint8) + img[10:50, 10:60] = (255, 0, 0) # blue rectangle + _, buf = cv2.imencode(".jpg", img) + return buf.tobytes() + + +SAMPLE_QUADS_JSON = json.dumps([ + { + "subject": "电缆接头", + "predicate": "位于", + "object": "配电箱左侧", + "qualifier": "2024年检修", + "bbox": {"x": 10, "y": 10, "w": 40, "h": 30}, + } +]) + + +@pytest.fixture +def image_bytes(): + return _make_test_image_bytes() + + +@pytest.fixture +def req(): + return ImageExtractRequest(file_path="image/test.jpg", task_id=1) + + +@pytest.mark.asyncio +async def test_extract_quads_returns_items(mock_llm, mock_storage, image_bytes, req): + mock_storage.download_bytes = AsyncMock(return_value=image_bytes) + mock_llm.chat_vision = AsyncMock(return_value=SAMPLE_QUADS_JSON) + mock_storage.upload_bytes = AsyncMock(return_value=None) + + from app.services.image_service import extract_quads + result = await extract_quads(req, mock_llm, mock_storage) + + assert len(result.items) == 1 + item = result.items[0] + assert item.subject == "电缆接头" + assert item.predicate == "位于" + assert item.bbox.x == 10 + assert item.bbox.y == 10 + assert item.cropped_image_path == "crops/1/0.jpg" + + +@pytest.mark.asyncio +async def test_crop_is_uploaded(mock_llm, mock_storage, image_bytes, req): + mock_storage.download_bytes = AsyncMock(return_value=image_bytes) + mock_llm.chat_vision = AsyncMock(return_value=SAMPLE_QUADS_JSON) + mock_storage.upload_bytes = AsyncMock(return_value=None) + + from app.services.image_service import extract_quads + await extract_quads(req, mock_llm, mock_storage) + + # upload_bytes called once for the crop + mock_storage.upload_bytes.assert_called_once() + call_args = mock_storage.upload_bytes.call_args + assert call_args.args[1] == "crops/1/0.jpg" + + +@pytest.mark.asyncio +async def test_out_of_bounds_bbox_is_clamped(mock_llm, mock_storage, req): + img = _make_test_image_bytes(width=50, height=40) + mock_storage.download_bytes = AsyncMock(return_value=img) + + # bbox goes outside image boundary + oob_json = json.dumps([{ + "subject": "test", + "predicate": "rel", + "object": "obj", + "qualifier": None, + "bbox": {"x": 30, "y": 20, "w": 100, "h": 100}, # extends beyond 50x40 + }]) + mock_llm.chat_vision = AsyncMock(return_value=oob_json) + mock_storage.upload_bytes = AsyncMock(return_value=None) + + from app.services.image_service import extract_quads + # Should not raise; bbox is clamped + result = await extract_quads(req, mock_llm, mock_storage) + assert len(result.items) == 1 + + +@pytest.mark.asyncio +async def test_llm_parse_error_raised(mock_llm, mock_storage, image_bytes, req): + mock_storage.download_bytes = AsyncMock(return_value=image_bytes) + mock_llm.chat_vision = AsyncMock(return_value="bad json {{") + + from app.services.image_service import extract_quads + with pytest.raises(LLMParseError): + await extract_quads(req, mock_llm, mock_storage)