diff --git a/docs/TESTING.md b/docs/TESTING.md index 3cb14fb..a99c97b 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -2,7 +2,7 @@ | Level | Command / workflow | What it verifies | What it does not verify | | --- | --- | --- | --- | -| Python unit tests | `cd inference && python -m unittest discover -s tests -v` | Confidence handling, model path and local URL restrictions using mock objects | Actual trained model or image decoding | +| Python unit tests | `cd inference && python -m unittest discover -s tests -v` | Confidence handling, model path and local URL restrictions, plus independent concurrent image request outputs using mock objects | Actual trained model or image decoding | | Vue build | `cd frontend && npm ci && npm run build` | The frontend bundle compiles | Browser integration or API responses | | Spring Boot checks | `cd backend && mvn -Dtest=DeepSeekControllerTest,FileControllerTest package` | Backend compilation, selected input/control tests | Database integration or paid AI connectivity | | Documentation links | `python scripts/check_docs_links.py` | Relative Markdown links resolve | External link availability | @@ -23,4 +23,4 @@ The optional test installs CPU PyTorch/Ultralytics packages and may download sub ## Before claiming production readiness -Run complete image/video/camera inference against the local Spring Boot upload/database flow, verify model-class mappings and nonempty/empty detections, profile CPU/GPU resource usage, test overlapping user sessions, audit upload authentication and content validation, and document the provenance/redistribution permissions of sample data and pretrained model assets. +The image route now uses per-request output paths and no longer mutates shared video state; unit tests simulate overlapping image calls. This does not prove end-to-end concurrency or cover the still-shared video/camera state. Run complete image/video/camera inference against the local Spring Boot upload/database flow, verify model-class mappings and nonempty/empty detections, profile CPU/GPU resource usage, test overlapping user sessions, audit upload authentication and content validation, and document the provenance/redistribution permissions of sample data and pretrained model assets. diff --git a/inference/image_requests.py b/inference/image_requests.py new file mode 100644 index 0000000..df41996 --- /dev/null +++ b/inference/image_requests.py @@ -0,0 +1,58 @@ +"""Per-request image prediction orchestration for the local CropLens demo. + +The video/camera routes still maintain their own shared mutable state and +need a separate concurrency review; this helper only isolates images. +""" +import json +import uuid +from pathlib import Path + + +def process_image(data, weight_path, threshold, predictor_class, upload, output_dir="./runs"): + """Predict one image and return the existing JSON-compatible response shape. + + All mutable state and the rendered output filename are local to a single + request; the temporary image is removed even if prediction/upload fails. + """ + response = { + "username": data.get("username"), + "weight": data["weight"], + "conf": data["conf"], + "startTime": data.get("startTime"), + "inputImg": data["inputImg"], + "kind": data["kind"], + } + folder = Path(output_dir) + folder.mkdir(parents=True, exist_ok=True) + output = folder / ("result_" + uuid.uuid4().hex + ".jpg") + try: + predictor = predictor_class( + weights_path=weight_path, + img_path=response["inputImg"], + save_path=str(output), + kind=response["kind"], + conf=threshold, + ) + result = predictor.predict() + if result["labels"] == "预测失败": + response.update({"status": 400, "message": "该图片无法识别,请重新上传!"}) + return response + if not output.is_file(): + response.update({"status": 400, "message": "预测结果图片未生成"}) + return response + url = upload(str(output)) + if not url: + response.update({"status": 400, "message": "预测图片上传失败"}) + return response + response.update({ + "status": 200, + "message": "预测成功", + "outImg": url, + "allTime": result["allTime"], + "confidence": json.dumps(result["confidences"]), + "label": json.dumps(result["labels"], ensure_ascii=False), + }) + return response + finally: + if output.is_file(): + output.unlink() diff --git a/inference/main.py b/inference/main.py index 566d90d..7cd2511 100644 --- a/inference/main.py +++ b/inference/main.py @@ -7,6 +7,7 @@ from ultralytics import YOLO from predict import predictImg from input_validation import resolve_weight_file, validate_local_media_url +from image_requests import process_image from flask_socketio import SocketIO, emit @@ -71,34 +72,14 @@ def predictImg(self): raise ValueError("Invalid crop type") except (ValueError, TypeError): return json.dumps({"status": 400, "message": "无效的模型、图片地址或预测参数"}, ensure_ascii=False), 400 - self.data.clear() - self.data.update({ - "username": data['username'], "weight": data['weight'], - "conf": data['conf'], "startTime": data['startTime'], - "inputImg": data['inputImg'], - "kind": data['kind'] - }) - print(self.data) - predict = predictImg.ImagePredictor(weights_path=weight_path, - img_path=self.data["inputImg"], save_path='./runs/result.jpg', kind=self.data["kind"], - conf=threshold) - # 执行预测 - results = predict.predict() - uploadedUrl = self.upload('./runs/result.jpg') - if results['labels'] != '预测失败': - self.data["status"] = 200 - self.data["message"] = "预测成功" - self.data["outImg"] = uploadedUrl - self.data["allTime"] = results['allTime'] - self.data["confidence"] = json.dumps(results['confidences']) - self.data["label"] = json.dumps(results['labels']) - else: - self.data["status"] = 400 - self.data["message"] = "该图片无法识别,请重新上传!" - path = self.data["inputImg"].split('/')[-1] - if os.path.exists('./' + path): - os.remove('./' + path) - return json.dumps(self.data, ensure_ascii=False) + response = process_image( + data=data, + weight_path=weight_path, + threshold=threshold, + predictor_class=predictImg.ImagePredictor, + upload=self.upload, + ) + return json.dumps(response, ensure_ascii=False) def predictVideo(self): """视频流处理接口""" diff --git a/inference/tests/test_image_requests.py b/inference/tests/test_image_requests.py new file mode 100644 index 0000000..0c98926 --- /dev/null +++ b/inference/tests/test_image_requests.py @@ -0,0 +1,107 @@ +"""Regression tests for request-local image results, without torch or Flask.""" +import tempfile +import unittest +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from threading import Barrier + +from image_requests import process_image + + +def payload(image): + return { + "username": image, + "weight": "wheat_best.pt", + "conf": "0.5", + "startTime": "2026-10-08", + "inputImg": image, + "kind": "wheat", + } + + +class ImageRequestTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + + def test_success_uses_unique_file_and_cleans_up(self): + class Predictor: + def __init__(self, **params): + self.params = params + + def predict(self): + Path(self.params["save_path"]).write_text("rendered", encoding="utf-8") + return {"labels": ["Leaf_Rust"], "confidences": ["73.00%"], "allTime": "0.2秒"} + + captured = [] + + def upload(filename): + captured.append(Path(filename).read_text(encoding="utf-8")) + return "http://localhost:9999/files/test.jpg" + + result = process_image(payload("image-a"), "/weights/wheat_best.pt", 0.5, Predictor, upload, self.root) + self.assertEqual(result["status"], 200) + self.assertEqual(result["username"], "image-a") + self.assertEqual(result["outImg"], "http://localhost:9999/files/test.jpg") + self.assertEqual(result["confidence"], '["73.00%"]') + self.assertEqual(captured, ["rendered"]) + self.assertEqual(list(self.root.iterdir()), []) + + def test_no_detection_does_not_upload(self): + class Predictor: + def __init__(self, **kwargs): + pass + + def predict(self): + return {"labels": "预测失败", "confidences": "0.00%", "allTime": "0.1秒"} + + result = process_image( + payload("empty"), "/weights/wheat_best.pt", 0.5, Predictor, + lambda _: self.fail("should not upload failed predictions"), self.root + ) + self.assertEqual(result["status"], 400) + self.assertEqual(list(self.root.iterdir()), []) + + def test_failed_upload_removes_rendered_file(self): + class Predictor: + def __init__(self, **kwargs): + self.file = kwargs["save_path"] + + def predict(self): + Path(self.file).write_bytes(b"image") + return {"labels": ["Leaf_Rust"], "confidences": ["70.00%"], "allTime": "0.1秒"} + + result = process_image(payload("x"), "model.pt", 0.5, Predictor, lambda _: None, self.root) + self.assertEqual(result["status"], 400) + self.assertEqual(list(self.root.iterdir()), []) + + def test_parallel_requests_do_not_overwrite_each_other(self): + barrier = Barrier(2) + + class Predictor: + def __init__(self, **kwargs): + self.filename = kwargs["save_path"] + self.image = kwargs["img_path"] + + def predict(self): + Path(self.filename).write_text(self.image, encoding="utf-8") + barrier.wait(timeout=10) + return {"labels": [self.image], "confidences": ["75.00%"], "allTime": "0.1秒"} + + def upload(filename): + return Path(filename).read_text(encoding="utf-8") + + def predict(label): + return process_image(payload(label), "model.pt", 0.5, Predictor, upload, self.root) + + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(predict, ["first", "second"])) + self.assertEqual([r["outImg"] for r in results], ["first", "second"]) + self.assertEqual([r["username"] for r in results], ["first", "second"]) + self.assertEqual([r["status"] for r in results], [200, 200]) + self.assertEqual(list(self.root.iterdir()), []) + + +if __name__ == "__main__": + unittest.main()