From aa52c66a0a7d38012febf620a50597fea2d32ae7 Mon Sep 17 00:00:00 2001 From: Amit Moryossef Date: Mon, 23 Mar 2026 04:45:16 -0400 Subject: [PATCH 1/2] feat(server): add GET /segments endpoint returning JSON in seconds - GET /segments?pose= resolves pose, runs segmentation, returns {"sign": [{"start": 0.0, "end": 1.2}], "sentence": [...]} in seconds - Extracts shared load_pose() and tiers_to_seconds() helpers - Gzip-compressed JSON response - CORS headers on all responses (after_request hook) - Cache-Control: public, max-age=86400 (1 day) on GET /segments - OPTIONS preflight handled for /segments Co-Authored-By: Claude Sonnet 4.6 --- sign_language_segmentation/server.py | 73 +++++++++++++++++++++++++--- 1 file changed, 65 insertions(+), 8 deletions(-) diff --git a/sign_language_segmentation/server.py b/sign_language_segmentation/server.py index 97e0d5c..c16778f 100644 --- a/sign_language_segmentation/server.py +++ b/sign_language_segmentation/server.py @@ -1,3 +1,5 @@ +import gzip +import json import os import traceback from datetime import datetime, UTC @@ -10,12 +12,25 @@ app = Flask(__name__) +CACHE_TTL = 86400 # 1 day in seconds + def resolve_path(uri: str): # Map gs:// URIs to the gcsfuse mount point, or return as-is return uri.replace("gs://", "/mnt/") +def add_cors(response): + response.headers["Access-Control-Allow-Origin"] = "*" + response.headers["Access-Control-Allow-Headers"] = "Content-Type" + return response + + +@app.after_request +def after_request(response): + return add_cors(response) + + @app.errorhandler(Exception) def handle_exception(e): print("Exception", e) @@ -28,6 +43,32 @@ def handle_exception(e): return make_response(jsonify(message=message, code=code), code) +def load_pose(uri: str) -> Pose: + pose_file_path = Path(resolve_path(uri)) + if not pose_file_path.exists(): + raise FileNotFoundError(f"File does not exist: {uri}") + with pose_file_path.open("rb") as f: + return Pose.read(f) + + +def tiers_to_seconds(tiers: dict, fps: float) -> dict: + """Convert frame-index segment dicts to seconds.""" + return { + tier: [{"start": round(seg["start"] / fps, 4), "end": round(seg["end"] / fps, 4)} + for seg in segments] + for tier, segments in tiers.items() + } + + +def gzip_json(data: dict): + body = json.dumps(data, separators=(",", ":")).encode("utf-8") + compressed = gzip.compress(body) + response = make_response(compressed, 200) + response.headers["Content-Type"] = "application/json" + response.headers["Content-Encoding"] = "gzip" + return response + + @app.route('/health', methods=['GET']) def health_check(): body = { @@ -38,6 +79,28 @@ def health_check(): return make_response(jsonify(body), 200) +@app.route("/segments", methods=['GET', 'OPTIONS']) +def get_segments(): + if request.method == 'OPTIONS': + return make_response("", 204) + + pose_uri = request.args.get("pose") + if not pose_uri: + abort(make_response(jsonify(message="Missing `pose` query parameter"), 400)) + + pose = load_pose(pose_uri) + + if len(pose.body.data) == 1: + return gzip_json({"sign": [], "sentence": []}) + + _eaf, tiers = segment_pose(pose) + result = tiers_to_seconds(tiers, pose.body.fps) + + response = gzip_json({"sign": result["SIGN"], "sentence": result["SENTENCE"]}) + response.headers["Cache-Control"] = f"public, max-age={CACHE_TTL}" + return response + + @app.route("/", methods=['POST']) def pose_segmentation(): body = request.get_json() @@ -49,18 +112,12 @@ def pose_segmentation(): if output_file_path.exists(): return make_response(jsonify(message="Output file already exists", path=body["output"]), 208) - pose_file_path = Path(resolve_path(body["input"])) - if not pose_file_path.exists(): - raise FileNotFoundError("File does not exist") - - with pose_file_path.open("rb") as f: - pose = Pose.read(f) + pose = load_pose(body["input"]) if len(pose.body.data) == 1: - # segment_pose would error on a single-frame pose return make_response(jsonify(message="Pose has only one frame, no segmentation needed", path=body["output"]), 200) - eaf, tiers = segment_pose(pose) + eaf, _tiers = segment_pose(pose) output_file_path.parent.mkdir(parents=True, exist_ok=True) print("Saving .eaf to disk ...") From 8f089d69a5d9a7b9825ceb962faae664165b6121 Mon Sep 17 00:00:00 2001 From: Amit Moryossef Date: Mon, 23 Mar 2026 04:49:49 -0400 Subject: [PATCH 2/2] refactor(server): flask-cors/compress/caching, GET+POST on /, round to 3dp - GET /?pose= and POST / both live on / - flask-cors: CORS only on GET / - flask-compress + @compress.compressed(): automatic gzip - flask-caching SimpleCache: server-side cache by URL (1 day TTL) - Cache-Control: public, max-age=86400 on GET response - Round segment times to 3 decimal places (1ms precision) - Add Flask-Cors, Flask-Compress, Flask-Caching to server extras Co-Authored-By: Claude Sonnet 4.6 --- pyproject.toml | 3 ++ sign_language_segmentation/server.py | 59 +++++++++++----------------- 2 files changed, 25 insertions(+), 37 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d3ffacb..71a5a56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,9 @@ server = [ "Flask", "Werkzeug", "gunicorn", + "Flask-Cors", + "Flask-Compress", + "Flask-Caching", ] [tool.ruff] diff --git a/sign_language_segmentation/server.py b/sign_language_segmentation/server.py index c16778f..8223762 100644 --- a/sign_language_segmentation/server.py +++ b/sign_language_segmentation/server.py @@ -1,16 +1,20 @@ -import gzip -import json import os import traceback from datetime import datetime, UTC from pathlib import Path from flask import Flask, request, abort, make_response, jsonify +from flask_caching import Cache +from flask_compress import Compress +from flask_cors import CORS from pose_format import Pose from sign_language_segmentation.bin import segment_pose app = Flask(__name__) +compress = Compress(app) +cache = Cache(app, config={"CACHE_TYPE": "SimpleCache"}) +CORS(app, resources={r"/": {"methods": ["GET", "OPTIONS"]}}) CACHE_TTL = 86400 # 1 day in seconds @@ -20,29 +24,6 @@ def resolve_path(uri: str): return uri.replace("gs://", "/mnt/") -def add_cors(response): - response.headers["Access-Control-Allow-Origin"] = "*" - response.headers["Access-Control-Allow-Headers"] = "Content-Type" - return response - - -@app.after_request -def after_request(response): - return add_cors(response) - - -@app.errorhandler(Exception) -def handle_exception(e): - print("Exception", e) - traceback.print_exc() - - code = e.code if hasattr(e, "code") else 500 - message = str(e) - print("HTTP exception", code, message) - - return make_response(jsonify(message=message, code=code), code) - - def load_pose(uri: str) -> Pose: pose_file_path = Path(resolve_path(uri)) if not pose_file_path.exists(): @@ -52,21 +33,23 @@ def load_pose(uri: str) -> Pose: def tiers_to_seconds(tiers: dict, fps: float) -> dict: - """Convert frame-index segment dicts to seconds.""" return { - tier: [{"start": round(seg["start"] / fps, 4), "end": round(seg["end"] / fps, 4)} + tier: [{"start": round(seg["start"] / fps, 3), "end": round(seg["end"] / fps, 3)} for seg in segments] for tier, segments in tiers.items() } -def gzip_json(data: dict): - body = json.dumps(data, separators=(",", ":")).encode("utf-8") - compressed = gzip.compress(body) - response = make_response(compressed, 200) - response.headers["Content-Type"] = "application/json" - response.headers["Content-Encoding"] = "gzip" - return response +@app.errorhandler(Exception) +def handle_exception(e): + print("Exception", e) + traceback.print_exc() + + code = e.code if hasattr(e, "code") else 500 + message = str(e) + print("HTTP exception", code, message) + + return make_response(jsonify(message=message, code=code), code) @app.route('/health', methods=['GET']) @@ -79,7 +62,9 @@ def health_check(): return make_response(jsonify(body), 200) -@app.route("/segments", methods=['GET', 'OPTIONS']) +@app.route("/", methods=['GET', 'OPTIONS']) +@compress.compressed() +@cache.cached(timeout=CACHE_TTL, query_string=True) def get_segments(): if request.method == 'OPTIONS': return make_response("", 204) @@ -91,12 +76,12 @@ def get_segments(): pose = load_pose(pose_uri) if len(pose.body.data) == 1: - return gzip_json({"sign": [], "sentence": []}) + return jsonify(sign=[], sentence=[]) _eaf, tiers = segment_pose(pose) result = tiers_to_seconds(tiers, pose.body.fps) - response = gzip_json({"sign": result["SIGN"], "sentence": result["SENTENCE"]}) + response = make_response(jsonify(sign=result["SIGN"], sentence=result["SENTENCE"])) response.headers["Cache-Control"] = f"public, max-age={CACHE_TTL}" return response