Skip to content

Commit e5257c2

Browse files
committed
⚡️📈🚚 Update crab measurment logic, incorporate into web ui and database
1 parent a882dbd commit e5257c2

29 files changed

Lines changed: 1555 additions & 101 deletions
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""add media measurement_mode
2+
3+
Revision ID: 9a2b7c1d4e6f
4+
Revises: f8e9a1b2c3d4
5+
Create Date: 2026-03-23
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from typing import Sequence, Union
11+
12+
import sqlalchemy as sa
13+
from alembic import op
14+
15+
revision: str = "9a2b7c1d4e6f"
16+
down_revision: Union[str, None] = "f8e9a1b2c3d4"
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
with op.batch_alter_table("media") as batch:
23+
batch.add_column(
24+
sa.Column(
25+
"measurement_mode",
26+
sa.Enum("isotropic", "homography", name="mediameasurementmode", native_enum=False),
27+
nullable=False,
28+
server_default="homography",
29+
)
30+
)
31+
32+
33+
def downgrade() -> None:
34+
with op.batch_alter_table("media") as batch:
35+
batch.drop_column("measurement_mode")
36+
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""add calibration table; media.active_calibration_id
2+
3+
Revision ID: f8e9a1b2c3d4
4+
Revises: e2f3a4b5c6d7
5+
Create Date: 2026-03-23
6+
7+
"""
8+
from __future__ import annotations
9+
10+
from typing import Sequence, Union
11+
12+
import sqlalchemy as sa
13+
from alembic import op
14+
15+
revision: str = "f8e9a1b2c3d4"
16+
down_revision: Union[str, None] = "e2f3a4b5c6d7"
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
op.create_table(
23+
"calibration",
24+
sa.Column("id", sa.Uuid(), nullable=False),
25+
sa.Column("source_media_id", sa.Uuid(), nullable=False),
26+
sa.Column("frame_index", sa.Integer(), nullable=True),
27+
sa.Column("time_seconds", sa.Float(), nullable=True),
28+
sa.Column("corners_json", sa.Text(), nullable=False),
29+
sa.Column("reference_edge_index", sa.Integer(), nullable=False),
30+
sa.Column("reference_length_mm", sa.Float(), nullable=False),
31+
sa.Column("ref_width_px", sa.Integer(), nullable=True),
32+
sa.Column("ref_height_px", sa.Integer(), nullable=True),
33+
sa.Column("mm_per_px", sa.Float(), nullable=False),
34+
sa.Column("label", sa.String(length=512), nullable=True),
35+
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False),
36+
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False),
37+
sa.ForeignKeyConstraint(
38+
["source_media_id"],
39+
["media.id"],
40+
name=op.f("fk_calibration_source_media_id_media"),
41+
ondelete="CASCADE",
42+
),
43+
sa.PrimaryKeyConstraint("id", name=op.f("pk_calibration")),
44+
)
45+
op.create_index(op.f("ix_calibration_source_media_id"), "calibration", ["source_media_id"], unique=False)
46+
47+
# SQLite cannot ALTER TABLE ADD CONSTRAINT; keep column + FK + index in one batch_alter_table.
48+
with op.batch_alter_table("media") as batch:
49+
batch.add_column(
50+
sa.Column(
51+
"active_calibration_id",
52+
sa.Uuid(),
53+
nullable=True,
54+
)
55+
)
56+
batch.create_foreign_key(
57+
op.f("fk_media_active_calibration_id_calibration"),
58+
"calibration",
59+
["active_calibration_id"],
60+
["id"],
61+
ondelete="SET NULL",
62+
)
63+
batch.create_index(
64+
op.f("ix_media_active_calibration_id"),
65+
["active_calibration_id"],
66+
unique=False,
67+
)
68+
69+
70+
def downgrade() -> None:
71+
with op.batch_alter_table("media") as batch:
72+
batch.drop_constraint(op.f("fk_media_active_calibration_id_calibration"), type_="foreignkey")
73+
batch.drop_index(op.f("ix_media_active_calibration_id"))
74+
batch.drop_column("active_calibration_id")
75+
76+
op.drop_index(op.f("ix_calibration_source_media_id"), table_name="calibration")
77+
op.drop_table("calibration")

apps/web/crabspy_web.egg-info/PKG-INFO

Lines changed: 13 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -39,24 +39,21 @@ PYTHONPATH=apps/web uvicorn crabspy_web.app:app --reload --app-dir apps/web
3939

4040
Environment variables (optional):
4141

42-
- `CRABSPY_DATA_DIR` — root for `uploads/`, `db/`, `exports/`, `cache/`, and `config/` (default: `<repo>/data`).
43-
- `CRABSPY_DATABASE_URL` — SQLAlchemy URL. If unset, the app uses `data/config/database_url` (one line) when present, otherwise SQLite at `CRABSPY_DATA_DIR/db/project.sqlite`. When this env var **is** set, it always wins and the Settings → Database form is read-only.
42+
- `CRABSPY_DATA_DIR` — root for `uploads/`, `db/`, `exports/`, `cache/` (default: `<repo>/data`).
43+
- `CRABSPY_DATABASE_URL` — SQLAlchemy URL (default: SQLite under `CRABSPY_DATA_DIR/db/project.sqlite`).
4444

45-
**Project databases (Postgres-ready):** use one SQLite file per study or campaign, e.g. `data/db/coastal_site_2025.sqlite`, with URL `sqlite:////absolute/path/to/data/db/coastal_site_2025.sqlite` (four slashes after `sqlite:` for absolute paths on Unix). Switch in the UI under **Database** (`/settings/database`) or by editing `data/config/database_url`. For PostgreSQL: `postgresql+psycopg://user:pass@host:5432/dbname` after `pip install -e ".[postgres]"`.
45+
## Phase 3b — annotations and backup
4646

47-
**Web UI:** register draft media and export CSV from [`/media/`](http://127.0.0.1:8000/media/) (see nav). Edit or delete a row via **Edit** on the list. On startup the app applies Alembic migrations to the active database automatically.
47+
Annotations are stored relationally (`annotation`, `annotation_point`) with normalized coordinates. The web UI supports **points and polylines** on **video** and **image** viewers; `POST /media/{id}/annotations` accepts the same JSON schema.
4848

49-
**Storage paths:** `storage_path` is a string pointing at your files (e.g. on an external SSD or NAS). The app does not copy or move originals; it only records the path. Optional future feature: copy uploads into `data/uploads/` for self-contained projects.
49+
- **Optional label** — Text field on the view page; stored on the annotation row.
50+
- **Reference dimensions** — When the browser knows intrinsic size (`videoWidth` / `videoHeight` or image `naturalWidth` / `naturalHeight`), the client sends `ref_width_px` and `ref_height_px` together so exports can compute pixel-space lengths. Omit if metadata is not ready yet.
51+
- **CSV export** — `GET /media/export_annotations.csv` includes per-vertex rows plus `path_length_norm`, `path_length_px` (when refs are set), and `edge_length_norm` / `edge_length_px` for each segment.
5052

51-
**Viewing in the browser (Phase 3a):** For each media row, **View** opens `/media/<uuid>/view` (image via `<img>`, video via `<video controls>`). The file is served at `/media/<uuid>/file` with a guessed MIME type. Relative paths resolve under `CRABSPY_DATA_DIR` (must stay inside that directory after normalization); absolute paths are read as-is. Starlette’s `FileResponse` supports HTTP range requests for seeking in video.
53+
**Backup (recommended before migrations or risky edits):**
5254

53-
**Video seek:** On the view page, use **Time** (seconds or `MM:SS` / `HH:MM:SS`) or **Frame** (0-based index; requires **Frame rate** on the media row). Deep links: `?t=12.5` (seconds) or `?f=300` (frame index). If both are present, `f` wins when frame rate is set; otherwise `t` is used.
54-
55-
**Video annotation spike (Phase 3b prep):** On the media **View** page for video-like rows, use **Add annotation points** to overlay clicks on the video (normalized 0–1 coordinates, letterbox-aware). Points are stored in the `video_spike_annotation` table via `POST /media/<uuid>/video-spike` (JSON body: `x_norm`, `y_norm`, `time_seconds`, optional `frame_index`) and `POST /media/<uuid>/video-spike/<point_id>/delete`. This is a thin vertical slice to validate overlay + persistence before the full annotation schema.
56-
57-
**CI:** GitHub Actions runs `pytest tests/web` on push and pull requests (see [`.github/workflows/ci.yml`](../../.github/workflows/ci.yml)).
58-
59-
**Bulk import:** [`/media/import`](http://127.0.0.1:8000/media/import) accepts a UTF-8 CSV (headers in row 1). Required: a path column (`storage_path`, `path`, or `video_path`). Optional: `collected_at` / `date_collected` / `date`, `sample_code`, `site_name`, `location_name`, `notes`, `original_filename`, plus `camera_id`, `deployment_time`, `deployment_type`, `latitude`/`lat`, `longitude`/`lon`/`lng`. Dates accept ISO-8601 or `YYYY-MM-DD` (and a few common formats). Rows with path, date, sample, site, and location name set are stored as `ready_for_processing`; others as `draft`. Optional camera/deployment/geo fields can be empty without blocking readiness. Duplicate `storage_path` values already in the database are skipped.
55+
1. **Annotations CSV** — Download from the [Media](http://127.0.0.1:8000/media/) page (“Annotations backup”) or open `GET /media/export_annotations.csv` (flattened rows, UTF-8). This is portable and version-control friendly.
56+
2. **Full database copy** — With the app **stopped** (and no other process holding the file), copy the active SQLite file (default: `CRABSPY_DATA_DIR/db/project.sqlite`, plus `-wal`/`-shm` if present) to a safe location. Restores the whole project, including media rows and uploads paths.
6057

6158
## Docker
6259

@@ -70,20 +67,12 @@ Then open `http://127.0.0.1:8000` and `http://127.0.0.1:8000/api/health`.
7067

7168
## Alembic
7269

73-
Configuration lives under `apps/web/alembic.ini`. Migrations are in `apps/web/alembic/versions/`.
74-
75-
The running app calls `alembic upgrade head` on startup for the resolved database URL (so a fresh clone usually only needs `pip install` and `uvicorn`). You can still run migrations manually:
70+
Configuration lives under `apps/web/alembic.ini`. After installing the package:
7671

7772
```bash
7873
cd apps/web
79-
export CRABSPY_DATABASE_URL=sqlite:////absolute/path/to/project.sqlite # optional override
74+
alembic revision --autogenerate -m "init"
8075
alembic upgrade head
8176
```
8277

83-
To generate a new revision after changing models:
84-
85-
```bash
86-
cd apps/web
87-
alembic revision --autogenerate -m "describe change"
88-
alembic upgrade head
89-
```
78+
(Autogenerate requires SQLAlchemy models to be wired in `alembic/env.py`.)

apps/web/crabspy_web.egg-info/SOURCES.txt

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,25 @@ crabspy_web/db/__init__.py
1212
crabspy_web/db/migrate.py
1313
crabspy_web/db/session.py
1414
crabspy_web/models/__init__.py
15+
crabspy_web/models/annotation.py
1516
crabspy_web/models/base.py
17+
crabspy_web/models/calibration.py
1618
crabspy_web/models/media.py
17-
crabspy_web/models/video_spike.py
1819
crabspy_web/routers/__init__.py
1920
crabspy_web/routers/health.py
2021
crabspy_web/routers/media.py
2122
crabspy_web/routers/pages.py
2223
crabspy_web/routers/settings.py
23-
crabspy_web/schemas/video_spike.py
24+
crabspy_web/schemas/annotation.py
25+
crabspy_web/schemas/calibration.py
2426
crabspy_web/services/__init__.py
27+
crabspy_web/services/annotation.py
28+
crabspy_web/services/annotation_geometry.py
29+
crabspy_web/services/calibration_math.py
30+
crabspy_web/services/calibration_measure.py
31+
crabspy_web/services/calibration_service.py
2532
crabspy_web/services/csv_export.py
2633
crabspy_web/services/media_csv_import.py
2734
crabspy_web/services/media_files.py
2835
crabspy_web/services/media_form_utils.py
29-
crabspy_web/services/media_readiness.py
30-
crabspy_web/services/video_spike.py
36+
crabspy_web/services/media_readiness.py

apps/web/crabspy_web/models/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,17 @@
22

33
from crabspy_web.models.annotation import Annotation, AnnotationKind, AnnotationPoint
44
from crabspy_web.models.base import Base
5-
from crabspy_web.models.media import Media, MediaKind, MediaProcessingStatus
5+
from crabspy_web.models.calibration import Calibration
6+
from crabspy_web.models.media import Media, MediaKind, MediaMeasurementMode, MediaProcessingStatus
67

78
__all__ = [
89
"Base",
910
"Annotation",
1011
"AnnotationKind",
1112
"AnnotationPoint",
13+
"Calibration",
1214
"Media",
1315
"MediaKind",
16+
"MediaMeasurementMode",
1417
"MediaProcessingStatus",
1518
]
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""Scale calibration from a reference rectangle (quadrat) on one video frame or image."""
2+
3+
from __future__ import annotations
4+
5+
import uuid
6+
from datetime import datetime
7+
8+
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text, Uuid, func
9+
from sqlalchemy.orm import Mapped, mapped_column, relationship
10+
11+
from crabspy_web.models.base import Base
12+
13+
14+
class Calibration(Base):
15+
"""Known-length edge of a quadrilateral in the image plane; defines mm per pixel for this project."""
16+
17+
__tablename__ = "calibration"
18+
19+
id: Mapped[uuid.UUID] = mapped_column(
20+
Uuid(as_uuid=True),
21+
primary_key=True,
22+
default=uuid.uuid4,
23+
)
24+
# Media on which the quadrat was drawn (frame_index / time apply to this clip).
25+
source_media_id: Mapped[uuid.UUID] = mapped_column(
26+
Uuid(as_uuid=True),
27+
ForeignKey("media.id", ondelete="CASCADE"),
28+
nullable=False,
29+
index=True,
30+
)
31+
32+
frame_index: Mapped[int | None] = mapped_column(Integer, nullable=True)
33+
time_seconds: Mapped[float | None] = mapped_column(Float, nullable=True)
34+
35+
# JSON array of 4 {"x_norm","y_norm"} in [0,1], clockwise order
36+
corners_json: Mapped[str] = mapped_column(Text, nullable=False)
37+
38+
# Which edge (0–3): edge i connects corner i to corner (i+1)%4 and has length reference_length_mm.
39+
reference_edge_index: Mapped[int] = mapped_column(Integer, nullable=False)
40+
41+
reference_length_mm: Mapped[float] = mapped_column(Float, nullable=False)
42+
43+
ref_width_px: Mapped[int | None] = mapped_column(Integer, nullable=True)
44+
ref_height_px: Mapped[int | None] = mapped_column(Integer, nullable=True)
45+
46+
# reference_length_mm / edge_length_px on that reference edge (isotropic scale).
47+
mm_per_px: Mapped[float] = mapped_column(Float, nullable=False)
48+
49+
label: Mapped[str | None] = mapped_column(String(512), nullable=True)
50+
51+
created_at: Mapped[datetime] = mapped_column(
52+
DateTime(timezone=True),
53+
nullable=False,
54+
server_default=func.now(),
55+
)
56+
updated_at: Mapped[datetime] = mapped_column(
57+
DateTime(timezone=True),
58+
nullable=False,
59+
server_default=func.now(),
60+
onupdate=func.now(),
61+
)
62+
63+
source_media: Mapped["Media"] = relationship(
64+
"Media",
65+
foreign_keys=[source_media_id],
66+
back_populates="calibrations_defined",
67+
)

apps/web/crabspy_web/models/media.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,14 @@
1010
DateTime,
1111
Enum,
1212
Float,
13+
ForeignKey,
1314
Integer,
1415
String,
1516
Text,
1617
Uuid,
1718
func,
1819
)
19-
from sqlalchemy.orm import Mapped, mapped_column
20+
from sqlalchemy.orm import Mapped, mapped_column, relationship
2021

2122
from crabspy_web.models.base import Base
2223

@@ -34,6 +35,11 @@ class MediaKind(str, enum.Enum):
3435
unknown = "unknown"
3536

3637

38+
class MediaMeasurementMode(str, enum.Enum):
39+
isotropic = "isotropic"
40+
homography = "homography"
41+
42+
3743
def _enum_values(enum_cls: type[enum.Enum]) -> list[str]:
3844
return [member.value for member in enum_cls]
3945

@@ -60,6 +66,12 @@ class Media(Base):
6066
nullable=False,
6167
default=MediaKind.unknown,
6268
)
69+
measurement_mode: Mapped[MediaMeasurementMode] = mapped_column(
70+
Enum(MediaMeasurementMode, values_callable=_enum_values, native_enum=False),
71+
nullable=False,
72+
default=MediaMeasurementMode.homography,
73+
server_default=MediaMeasurementMode.homography.value,
74+
)
6375

6476
# Study metadata (required before processing when status is ready_for_processing — enforced in services).
6577
collected_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
@@ -96,3 +108,21 @@ class Media(Base):
96108
server_default=func.now(),
97109
onupdate=func.now(),
98110
)
111+
112+
# Use this calibration's mm/px when interpreting carapace polylines on this media (may point to any calibration row).
113+
active_calibration_id: Mapped[uuid.UUID | None] = mapped_column(
114+
Uuid(as_uuid=True),
115+
ForeignKey("calibration.id", ondelete="SET NULL"),
116+
nullable=True,
117+
index=True,
118+
)
119+
120+
active_calibration: Mapped["Calibration | None"] = relationship(
121+
"Calibration",
122+
foreign_keys=[active_calibration_id],
123+
)
124+
calibrations_defined: Mapped[list["Calibration"]] = relationship(
125+
"Calibration",
126+
foreign_keys="Calibration.source_media_id",
127+
back_populates="source_media",
128+
)

0 commit comments

Comments
 (0)