calcent commited on
Commit
cd0d068
·
verified ·
1 Parent(s): e4a1eb4

Deploy crack detection app

Browse files
.dockerignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .git/
5
+ .DS_Store
6
+ *.output
.gitattributes CHANGED
@@ -1,35 +1 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
  *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  *.pth filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
.gitignore ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .DS_Store
Dockerfile ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Crack Detection & Measurement — container image for Hugging Face Spaces.
2
+ FROM python:3.12-slim
3
+
4
+ # Runtime library opencv-python-headless needs.
5
+ RUN apt-get update && apt-get install -y --no-install-recommends \
6
+ libglib2.0-0 \
7
+ && rm -rf /var/lib/apt/lists/*
8
+
9
+ WORKDIR /app
10
+
11
+ # Install the CPU-only PyTorch build first — far smaller than the default
12
+ # CUDA wheel, and Hugging Face's free hardware is CPU anyway. Pinning the
13
+ # same versions means the torch lines in requirements.txt are then no-ops.
14
+ RUN pip install --no-cache-dir \
15
+ torch==2.12.0 torchvision==0.27.0 \
16
+ --index-url https://download.pytorch.org/whl/cpu
17
+
18
+ COPY requirements.txt .
19
+ RUN pip install --no-cache-dir -r requirements.txt
20
+
21
+ COPY . .
22
+
23
+ # Hugging Face Spaces routes external traffic to this port.
24
+ EXPOSE 7860
25
+ CMD ["gunicorn", "--workers", "1", "--threads", "4", "--worker-class", "gthread", \
26
+ "--timeout", "180", "--bind", "0.0.0.0:7860", "app:app"]
README.md CHANGED
@@ -1,10 +1,84 @@
1
  ---
2
- title: Crack Detection
3
- emoji: 🐠
4
- colorFrom: blue
5
- colorTo: pink
6
  sdk: docker
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Crack Detection And Measurement
3
+ emoji: 🧱
4
+ colorFrom: gray
5
+ colorTo: red
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
  ---
10
 
11
+ # Crack Detection & Measurement
12
+
13
+ Upload a photo of a wall; the app detects the crack, measures its width and
14
+ length in real centimetres, and shows the segmented result.
15
+
16
+ > The block above is Hugging Face Spaces configuration. It is ignored when
17
+ > running locally and tells the Space to build from the `Dockerfile`.
18
+
19
+ ## Pipeline
20
+
21
+ 1. **Scale & rectification (ArUco)** — a printed ArUco marker in the photo
22
+ gives four points with known real-world coordinates. The wall plane's
23
+ homography is recovered and the image is warped to a fronto-parallel view
24
+ where **1 pixel = a fixed known mm**. This corrects camera angle and fixes
25
+ scale. See `aruco_scale.py`.
26
+ 2. **Segmentation (ML)** — `best_crack_model.pth`, a ResNet34-Unet, *locates*
27
+ the crack and produces a binary mask. See `crack_pipeline.py`.
28
+ 3. **Measurement (computer vision)** — within the ML-detected region, a
29
+ black-tophat operator (local intensity contrast) plus Otsu thresholding
30
+ re-segments the crack tightly on its actual dark pixels, not the model's
31
+ wider "crack zone". Width is then sampled along the crack centreline.
32
+ See `cv_crack.py`.
33
+
34
+ Reported: **max width**, **min width** (both excluding the crack's tapering
35
+ tips), and **total length**. The refined crack mask is shown as the
36
+ segmented image.
37
+
38
+ ## Print the marker
39
+
40
+ ```bash
41
+ ./venv/bin/python make_marker.py # -> aruco_marker_60mm_A4.pdf
42
+ ```
43
+
44
+ Print at **100% / Actual Size** (no scaling); verify the black square is
45
+ exactly **6.0 cm**. Tape it flat on the wall next to the crack and photograph
46
+ wall + marker together. The app serves the PDF at `/marker`.
47
+
48
+ ## Run
49
+
50
+ ```bash
51
+ ./run.sh
52
+ ```
53
+
54
+ Creates the virtualenv on first run, then serves at
55
+ <http://127.0.0.1:5001> (port 5000 is taken by macOS AirPlay). Override with
56
+ `PORT=8080 ./venv/bin/python app.py`.
57
+
58
+ ## Files
59
+
60
+ | File | Purpose |
61
+ |------------------------|--------------------------------------------------|
62
+ | `aruco_scale.py` | ArUco detection, plane rectification, mm scaling |
63
+ | `crack_pipeline.py` | Model loading, ML segmentation, pipeline, overlay|
64
+ | `cv_crack.py` | Intensity-profile crack-width measurement |
65
+ | `make_marker.py` | Generates the printable marker PDF |
66
+ | `app.py` | Flask server (`/`, `/analyze`, `/marker`, `/health`) |
67
+ | `templates/index.html` | Upload UI and results view |
68
+ | `best_crack_model.pth` | Trained segmentation weights |
69
+
70
+ ## API
71
+
72
+ `POST /analyze` — multipart form, field `image`. Returns JSON: base64 PNG
73
+ `overlay`, a `measurements` object (`summary` / `summary_mm` with max width,
74
+ min width, total length), and an `aruco` object describing marker detection.
75
+
76
+ ## Deploying
77
+
78
+ The app is Flask + PyTorch (CPU). For a public deployment:
79
+
80
+ - Replace the Flask dev server with a production server (`gunicorn`).
81
+ - Pin dependency versions in `requirements.txt`.
82
+ - It needs ~1.5–2 GB RAM (PyTorch + the 98 MB model).
83
+ - Good hosts: **Hugging Face Spaces** (free, built for ML demos), or a Docker
84
+ container on Fly.io / Render / Railway.
app.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Flask web app for crack detection and measurement."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import io
7
+ import os
8
+
9
+ import cv2
10
+ import numpy as np
11
+ from flask import Flask, jsonify, render_template, request, send_file
12
+ from PIL import Image
13
+
14
+ import aruco_scale
15
+ import crack_pipeline
16
+
17
+ app = Flask(__name__)
18
+ app.config["MAX_CONTENT_LENGTH"] = 25 * 1024 * 1024 # 25 MB upload cap
19
+
20
+
21
+ def _read_image(file_storage) -> np.ndarray:
22
+ """Decode an uploaded file into an RGB numpy array."""
23
+ image = Image.open(io.BytesIO(file_storage.read())).convert("RGB")
24
+ return np.array(image)
25
+
26
+
27
+ def _encode_png(image_rgb: np.ndarray) -> str:
28
+ """Encode an RGB array as a base64 PNG data URI."""
29
+ bgr = cv2.cvtColor(image_rgb, cv2.COLOR_RGB2BGR)
30
+ ok, buf = cv2.imencode(".png", bgr)
31
+ if not ok:
32
+ raise RuntimeError("PNG encoding failed")
33
+ return "data:image/png;base64," + base64.b64encode(buf).decode("ascii")
34
+
35
+
36
+ @app.route("/")
37
+ def index():
38
+ return render_template("index.html")
39
+
40
+
41
+ @app.route("/health")
42
+ def health():
43
+ return jsonify(status="ok")
44
+
45
+
46
+ @app.route("/marker")
47
+ def marker():
48
+ """Serve the printable ArUco marker PDF (generated by make_marker.py)."""
49
+ size = int(aruco_scale.DEFAULT_MARKER_LENGTH_MM)
50
+ pdf = os.path.join(os.path.dirname(__file__), f"aruco_marker_{size}mm_A4.pdf")
51
+ if not os.path.exists(pdf):
52
+ return jsonify(error="Marker PDF missing - run: python make_marker.py"), 404
53
+ return send_file(pdf, mimetype="application/pdf")
54
+
55
+
56
+ @app.route("/analyze", methods=["POST"])
57
+ def analyze():
58
+ """Analyse an upload and return only the computer-vision result.
59
+
60
+ Scale always comes from the ArUco marker (no manual options); the model
61
+ threshold is fixed. The response carries the CV overlay and the CV
62
+ measurement (intensity-based, refined within the ML-detected region).
63
+ """
64
+ if "image" not in request.files or request.files["image"].filename == "":
65
+ return jsonify(error="No image uploaded"), 400
66
+
67
+ try:
68
+ image_rgb = _read_image(request.files["image"])
69
+ except Exception:
70
+ return jsonify(error="Could not read the uploaded image"), 400
71
+
72
+ result = crack_pipeline.analyze(
73
+ image_rgb, use_aruco=True,
74
+ marker_length_mm=aruco_scale.DEFAULT_MARKER_LENGTH_MM,
75
+ )
76
+ overlay = result["overlay"]
77
+
78
+ return jsonify(
79
+ overlay=_encode_png(overlay),
80
+ measurements=result["measurements"],
81
+ aruco=result["aruco"],
82
+ image_size={"width": overlay.shape[1], "height": overlay.shape[0]},
83
+ )
84
+
85
+
86
+ if __name__ == "__main__":
87
+ # Port 5000 is taken by macOS AirPlay Receiver, so default to 5001.
88
+ port = int(os.environ.get("PORT", 5001))
89
+ print("Loading crack segmentation model...")
90
+ crack_pipeline.get_model()
91
+ print(f"Model ready. Open http://127.0.0.1:{port}")
92
+ app.run(host="127.0.0.1", port=port, debug=False)
aruco_marker_60mm_A4.pdf ADDED
Binary file (11.6 kB). View file
 
aruco_scale.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ArUco-based planar rectification and metric scaling.
2
+
3
+ A single ArUco marker lying flat on the wall provides four image points
4
+ whose real-world coordinates are known (a square of side `marker_length_mm`).
5
+ That is enough to recover the homography of the wall plane, so the image can
6
+ be warped to a fronto-parallel ("top-down") view in which **one pixel equals
7
+ a fixed, known number of millimetres** everywhere.
8
+
9
+ Crack measurements taken on that rectified image are therefore correct in
10
+ real-world units regardless of the camera angle.
11
+
12
+ Marker convention (must match the printed tag from `make_marker.py`):
13
+ dictionary = DICT_4X4_50, any marker ID is accepted.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import cv2
19
+ import numpy as np
20
+
21
+ # Printed tag and detector must agree on the dictionary.
22
+ ARUCO_DICT = cv2.aruco.DICT_4X4_50
23
+ DEFAULT_MARKER_ID = 0
24
+ DEFAULT_MARKER_LENGTH_MM = 60.0
25
+
26
+ # Upper bound on the rectified canvas; px_per_mm is reduced to respect it.
27
+ MAX_CANVAS_PX = 2600
28
+
29
+
30
+ def get_dictionary():
31
+ return cv2.aruco.getPredefinedDictionary(ARUCO_DICT)
32
+
33
+
34
+ def _detector() -> "cv2.aruco.ArucoDetector":
35
+ params = cv2.aruco.DetectorParameters()
36
+ # Sub-pixel corner refinement — critical for measurement accuracy.
37
+ params.cornerRefinementMethod = cv2.aruco.CORNER_REFINE_SUBPIX
38
+ return cv2.aruco.ArucoDetector(get_dictionary(), params)
39
+
40
+
41
+ def detect_markers(image_rgb: np.ndarray) -> list[dict]:
42
+ """Detect every ArUco marker. Returns [{id, corners(4x2 float32)}, ...].
43
+
44
+ Corner order per marker: top-left, top-right, bottom-right, bottom-left.
45
+ """
46
+ gray = cv2.cvtColor(image_rgb, cv2.COLOR_RGB2GRAY)
47
+ corners, ids, _ = _detector().detectMarkers(gray)
48
+ if ids is None:
49
+ return []
50
+ return [{"id": int(i), "corners": c.reshape(4, 2).astype(np.float32)}
51
+ for c, i in zip(corners, ids.flatten())]
52
+
53
+
54
+ def _marker_side_pixels(corners: np.ndarray) -> float:
55
+ """Average side length (px) of the marker quad in the image."""
56
+ edges = [np.linalg.norm(corners[i] - corners[(i + 1) % 4]) for i in range(4)]
57
+ return float(np.mean(edges))
58
+
59
+
60
+ def rectify_to_metric(image_rgb: np.ndarray,
61
+ marker_length_mm: float = DEFAULT_MARKER_LENGTH_MM,
62
+ marker_id: int | None = None) -> dict | None:
63
+ """Warp the image to a fronto-parallel, metric-scaled view.
64
+
65
+ Parameters
66
+ ----------
67
+ image_rgb : input RGB image.
68
+ marker_length_mm : printed side length of the marker's black square.
69
+ marker_id : which marker to use; None -> first one detected.
70
+
71
+ Returns
72
+ -------
73
+ dict with:
74
+ image : rectified RGB image (uniform scale).
75
+ mm_per_pixel : millimetres represented by one pixel of `image`.
76
+ marker_id : the marker that was used.
77
+ homography : 3x3 image -> rectified transform.
78
+ or None if no usable marker was found.
79
+ """
80
+ markers = detect_markers(image_rgb)
81
+ if not markers:
82
+ return None
83
+
84
+ if marker_id is not None:
85
+ markers = [m for m in markers if m["id"] == marker_id]
86
+ if not markers:
87
+ return None
88
+ marker = markers[0]
89
+ src = marker["corners"]
90
+
91
+ # Native resolution: keep the marker roughly its original pixel size so
92
+ # rectification neither over- nor under-samples the wall.
93
+ side_px = _marker_side_pixels(src)
94
+ if side_px < 8: # marker too small to scale from reliably
95
+ return None
96
+ px_per_mm = side_px / marker_length_mm
97
+
98
+ # The marker maps to a square of `side_px` pixels at the canvas origin.
99
+ s = marker_length_mm * px_per_mm
100
+ dst = np.array([[0, 0], [s, 0], [s, s], [0, s]], dtype=np.float32)
101
+ homography = cv2.getPerspectiveTransform(src, dst)
102
+
103
+ # Where do the four image corners land? -> canvas size + translation.
104
+ h, w = image_rgb.shape[:2]
105
+ img_corners = np.array([[[0, 0], [w, 0], [w, h], [0, h]]], dtype=np.float32)
106
+ warped = cv2.perspectiveTransform(img_corners, homography)[0]
107
+ min_xy = warped.min(axis=0)
108
+ max_xy = warped.max(axis=0)
109
+ out_w = max_xy[0] - min_xy[0]
110
+ out_h = max_xy[1] - min_xy[1]
111
+
112
+ if not np.isfinite([out_w, out_h]).all() or out_w < 1 or out_h < 1:
113
+ return None # near-degenerate (extreme viewing angle)
114
+
115
+ # Shift the warped content fully into positive coordinates.
116
+ translate = np.array([[1, 0, -min_xy[0]],
117
+ [0, 1, -min_xy[1]],
118
+ [0, 0, 1]], dtype=np.float64)
119
+ homography = translate @ homography
120
+
121
+ # Clamp the canvas size; shrink px_per_mm to match if needed.
122
+ longest = max(out_w, out_h)
123
+ if longest > MAX_CANVAS_PX:
124
+ shrink = MAX_CANVAS_PX / longest
125
+ scale = np.array([[shrink, 0, 0], [0, shrink, 0], [0, 0, 1]])
126
+ homography = scale @ homography
127
+ out_w *= shrink
128
+ out_h *= shrink
129
+ px_per_mm *= shrink
130
+
131
+ out_size = (int(np.ceil(out_w)), int(np.ceil(out_h)))
132
+ rectified = cv2.warpPerspective(image_rgb, homography, out_size,
133
+ flags=cv2.INTER_CUBIC,
134
+ borderMode=cv2.BORDER_CONSTANT,
135
+ borderValue=(127, 127, 127))
136
+
137
+ return {
138
+ "image": rectified,
139
+ "mm_per_pixel": 1.0 / px_per_mm,
140
+ "marker_id": marker["id"],
141
+ "homography": homography,
142
+ }
best_crack_model.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7edb53b12afa4bd3cfb8febcbe2c93270c2ff7b4c9bbd54079eb101d4a44ef70
3
+ size 97921935
crack_pipeline.py ADDED
@@ -0,0 +1,352 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Crack detection + measurement pipeline.
2
+
3
+ Stages
4
+ ------
5
+ 0. Scale : an ArUco marker (see aruco_scale.py) rectifies the wall
6
+ plane and fixes a known mm-per-pixel scale.
7
+ 1. Segmentation : ResNet34-Unet (segmentation_models_pytorch) locates the
8
+ crack -> binary mask.
9
+ 2. Measurement : computer vision (see cv_crack.py) refines the crack from
10
+ intensity contrast within the ML region; width is sampled
11
+ along the ML centreline, length is the centreline length.
12
+
13
+ Widths are in *pixels* until multiplied by the ArUco `mm_per_pixel` scale.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import threading
19
+
20
+ import cv2
21
+ import numpy as np
22
+ import torch
23
+ import segmentation_models_pytorch as smp
24
+ from skimage.morphology import skeletonize
25
+
26
+ import aruco_scale
27
+ import cv_crack
28
+
29
+ MODEL_PATH = "best_crack_model.pth"
30
+
31
+ # Encoder was pretrained on ImageNet, so inference uses ImageNet stats.
32
+ _IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
33
+ _IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
34
+
35
+ # Longest image side fed to the network. Larger -> finer cracks, more memory.
36
+ MAX_SIDE = 1024
37
+ # Encoder downsamples by 32, so network input dims must be multiples of 32.
38
+ _STRIDE = 32
39
+
40
+ _model: torch.nn.Module | None = None
41
+ _model_lock = threading.Lock()
42
+
43
+
44
+ def get_model() -> torch.nn.Module:
45
+ """Load the segmentation model once and cache it (thread-safe)."""
46
+ global _model
47
+ if _model is None:
48
+ with _model_lock:
49
+ if _model is None:
50
+ model = smp.Unet(
51
+ encoder_name="resnet34",
52
+ encoder_weights=None,
53
+ in_channels=3,
54
+ classes=1,
55
+ activation=None,
56
+ )
57
+ state = torch.load(MODEL_PATH, map_location="cpu")
58
+ if isinstance(state, dict) and "state_dict" in state:
59
+ state = state["state_dict"]
60
+ model.load_state_dict(state)
61
+ model.eval()
62
+ _model = model
63
+ return _model
64
+
65
+
66
+ # --------------------------------------------------------------------------
67
+ # Stage 1 - segmentation
68
+ # --------------------------------------------------------------------------
69
+ def _preprocess(image_rgb: np.ndarray):
70
+ """Resize within MAX_SIDE and pad to a multiple of the encoder stride."""
71
+ h, w = image_rgb.shape[:2]
72
+ scale = min(1.0, MAX_SIDE / max(h, w))
73
+ nh, nw = max(1, round(h * scale)), max(1, round(w * scale))
74
+ resized = cv2.resize(image_rgb, (nw, nh), interpolation=cv2.INTER_AREA)
75
+
76
+ pad_h = (-nh) % _STRIDE
77
+ pad_w = (-nw) % _STRIDE
78
+ padded = cv2.copyMakeBorder(resized, 0, pad_h, 0, pad_w, cv2.BORDER_REFLECT)
79
+
80
+ tensor = (padded.astype(np.float32) / 255.0 - _IMAGENET_MEAN) / _IMAGENET_STD
81
+ tensor = torch.from_numpy(tensor).permute(2, 0, 1).unsqueeze(0)
82
+ return tensor, (nh, nw)
83
+
84
+
85
+ def segment(image_rgb: np.ndarray, threshold: float = 0.5) -> tuple[np.ndarray, np.ndarray]:
86
+ """Run the network on an RGB image.
87
+
88
+ Returns
89
+ -------
90
+ mask : uint8 array (0/255) at the original image resolution.
91
+ proba : float32 crack-probability map at the original resolution.
92
+ """
93
+ h, w = image_rgb.shape[:2]
94
+ tensor, (nh, nw) = _preprocess(image_rgb)
95
+
96
+ model = get_model()
97
+ with torch.no_grad():
98
+ logits = model(tensor)
99
+ proba = torch.sigmoid(logits)[0, 0].cpu().numpy()
100
+
101
+ proba = proba[:nh, :nw] # drop padding
102
+ proba = cv2.resize(proba, (w, h), interpolation=cv2.INTER_LINEAR)
103
+ mask = (proba >= threshold).astype(np.uint8) * 255
104
+ return mask, proba
105
+
106
+
107
+ # --------------------------------------------------------------------------
108
+ # Stage 2 - measurement
109
+ # --------------------------------------------------------------------------
110
+ def _skeleton_length_px(skeleton: np.ndarray) -> float:
111
+ """Length of a 1-px skeleton, counting diagonal steps as sqrt(2).
112
+
113
+ Each pair of 8-connected skeleton pixels contributes one step; the sum of
114
+ step lengths is halved because every step is counted from both ends.
115
+ """
116
+ ys, xs = np.nonzero(skeleton)
117
+ if len(ys) == 0:
118
+ return 0.0
119
+ if len(ys) == 1:
120
+ return 1.0
121
+
122
+ total = 0.0
123
+ sk = skeleton.astype(bool)
124
+ h, w = sk.shape
125
+ for y, x in zip(ys, xs):
126
+ for dy, dx in ((-1, -1), (-1, 0), (-1, 1), (0, -1),
127
+ (0, 1), (1, -1), (1, 0), (1, 1)):
128
+ ny, nx = y + dy, x + dx
129
+ if 0 <= ny < h and 0 <= nx < w and sk[ny, nx]:
130
+ total += 1.4142135623730951 if (dy and dx) else 1.0
131
+ return total / 2.0
132
+
133
+
134
+ def measure_crack(ml_mask: np.ndarray, cv_mask: np.ndarray,
135
+ mm_per_pixel: float | None = None) -> dict:
136
+ """Final crack measurement combining the ML and CV masks.
137
+
138
+ Length comes from the *ML* mask's skeleton — it is continuous and
139
+ captures the crack's full extent. Width is sampled from the *CV* mask
140
+ (intensity-based, tight on the true dark crack) along that same
141
+ centreline, so widths reflect the real crack rather than the model's
142
+ wider "crack zone". The crack's tapering tips are excluded from the
143
+ width statistics.
144
+ """
145
+ ml = ml_mask > 0
146
+ skeleton = skeletonize(ml)
147
+ length_px = _skeleton_length_px(skeleton)
148
+ n_cracks = int(cv2.connectedComponents(ml.astype(np.uint8))[0]) - 1
149
+
150
+ cv_dist = cv2.distanceTransform((cv_mask > 0).astype(np.uint8),
151
+ cv2.DIST_L2, 5)
152
+ ys, xs = np.nonzero(skeleton)
153
+ max_w = min_w = 0.0
154
+ max_xy = min_xy = None
155
+
156
+ if ys.size:
157
+ # CV-measured width at every point of the ML centreline.
158
+ widths = 2.0 * cv_dist[ys, xs]
159
+ in_cv = widths > 0
160
+ typical = float(np.median(widths[in_cv])) if in_cv.any() else 0.0
161
+ margin = max(4.0, 1.5 * typical)
162
+
163
+ # Trust a width sample only away from (a) the crack's true tips,
164
+ # where it tapers to zero, and (b) gaps where the CV mask did not
165
+ # resolve the crack. Keep centreline samples farther than `margin`
166
+ # from any such point.
167
+ neigh = cv2.filter2D(skeleton.astype(np.uint8), -1,
168
+ np.ones((3, 3), np.float32),
169
+ borderType=cv2.BORDER_CONSTANT)
170
+ endpoints = skeleton & (neigh <= 2)
171
+ avoid = np.ones(skeleton.shape, np.uint8) # 0 marks tips / gaps
172
+ avoid[endpoints] = 0
173
+ avoid[ys[~in_cv], xs[~in_cv]] = 0
174
+ if (avoid == 0).any():
175
+ d_safe = cv2.distanceTransform(avoid, cv2.DIST_L2, 5)
176
+ safe = d_safe[ys, xs] > margin
177
+ else:
178
+ safe = np.ones(widths.shape, bool)
179
+
180
+ valid = in_cv & safe
181
+ if not valid.any(): # crack too short / fragmented to trim
182
+ valid = in_cv
183
+ if valid.any():
184
+ wv, xv, yv = widths[valid], xs[valid], ys[valid]
185
+ i_hi, i_lo = int(np.argmax(wv)), int(np.argmin(wv))
186
+ max_w, min_w = float(wv[i_hi]), float(wv[i_lo])
187
+ max_xy = [int(xv[i_hi]), int(yv[i_hi])]
188
+ min_xy = [int(xv[i_lo]), int(yv[i_lo])]
189
+
190
+ summary = {
191
+ "num_cracks": n_cracks,
192
+ "total_length_px": round(length_px, 1),
193
+ "max_width_px": round(max_w, 2),
194
+ "min_width_px": round(min_w, 2),
195
+ }
196
+ component = {
197
+ "id": 1,
198
+ "max_width_px": round(max_w, 2),
199
+ "min_width_px": round(min_w, 2),
200
+ "max_width_xy": max_xy,
201
+ "min_width_xy": min_xy,
202
+ }
203
+ result = {
204
+ "scale_known": mm_per_pixel is not None,
205
+ "mm_per_pixel": mm_per_pixel,
206
+ "summary": summary,
207
+ "components": [component],
208
+ }
209
+ if mm_per_pixel is not None:
210
+ result["summary_mm"] = {
211
+ "num_cracks": n_cracks,
212
+ "total_length_mm": round(length_px * mm_per_pixel, 2),
213
+ "max_width_mm": round(max_w * mm_per_pixel, 2),
214
+ "min_width_mm": round(min_w * mm_per_pixel, 2),
215
+ }
216
+ component["max_width_mm"] = round(max_w * mm_per_pixel, 2)
217
+ component["min_width_mm"] = round(min_w * mm_per_pixel, 2)
218
+ return result
219
+
220
+
221
+ # --------------------------------------------------------------------------
222
+ # Visualisation
223
+ # --------------------------------------------------------------------------
224
+ _FONT = cv2.FONT_HERSHEY_SIMPLEX
225
+ _ORANGE = (255, 140, 0)
226
+ _GREEN = (0, 230, 0) # widest point
227
+ _BLUE = (40, 130, 255) # narrowest point
228
+
229
+
230
+ def _put_label(img: np.ndarray, text: str, org: tuple[int, int],
231
+ color: tuple, scale: float = 0.6) -> None:
232
+ """Draw text with a black outline so it stays readable on any background."""
233
+ cv2.putText(img, text, org, _FONT, scale, (0, 0, 0), 4, cv2.LINE_AA)
234
+ cv2.putText(img, text, org, _FONT, scale, color, 2, cv2.LINE_AA)
235
+
236
+
237
+ def _mark_width(img: np.ndarray, xy: list, width_px: float,
238
+ width_mm: float | None, color: tuple, prefix: str) -> None:
239
+ """Mark a width point with its largest-inscribed circle, cross and label."""
240
+ radius = max(3, int(round(width_px / 2)))
241
+ cv2.circle(img, tuple(xy), radius, color, 2, cv2.LINE_AA)
242
+ cv2.drawMarker(img, tuple(xy), color, cv2.MARKER_CROSS, 18, 2)
243
+ value = f"{width_mm:.1f} mm" if width_mm is not None else f"{width_px:.0f} px"
244
+ _put_label(img, f"{prefix} {value}", (xy[0] + radius + 6, xy[1] + 5),
245
+ color, 0.6)
246
+
247
+
248
+ def make_overlay(image_rgb: np.ndarray, mask: np.ndarray,
249
+ measurements: dict | None = None,
250
+ annotate_each: bool = True) -> np.ndarray:
251
+ """Draw the crack mask, skeleton, tight outline and max-width markers.
252
+
253
+ `annotate_each` adds a `#id` label per crack (useful for the ML overlay,
254
+ which has a matching per-crack table; off for the CV overlay, which can
255
+ fragment into many pieces and would otherwise be cluttered).
256
+ """
257
+ overlay = image_rgb.copy()
258
+ crack = mask > 0
259
+
260
+ red = np.zeros_like(overlay)
261
+ red[crack] = (255, 40, 40)
262
+ overlay = cv2.addWeighted(overlay, 1.0, red, 0.5, 0)
263
+
264
+ overlay[skeletonize(crack)] = (255, 255, 0)
265
+
266
+ # Outline the crack precisely with its contour (hugs every branch) instead
267
+ # of an axis-aligned box, which for a thin diagonal crack would enclose a
268
+ # large mostly-empty area.
269
+ contours, _ = cv2.findContours(crack.astype(np.uint8),
270
+ cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
271
+ cv2.drawContours(overlay, contours, -1, _ORANGE, 2, cv2.LINE_AA)
272
+
273
+ if measurements:
274
+ comps = measurements.get("components", [])
275
+
276
+ if annotate_each:
277
+ for c in comps:
278
+ if c.get("centroid"):
279
+ cx, cy = c["centroid"]
280
+ _put_label(overlay, f"#{c['id']}", (cx + 5, cy),
281
+ _ORANGE, 0.55)
282
+
283
+ # Mark the globally widest and narrowest points. The distance-transform
284
+ # value at each is the radius of the largest circle fitting inside the
285
+ # crack there, so the drawn circle's diameter *is* that width.
286
+ widest = max(comps, key=lambda c: c["max_width_px"], default=None)
287
+ if widest and widest.get("max_width_xy"):
288
+ _mark_width(overlay, widest["max_width_xy"], widest["max_width_px"],
289
+ widest.get("max_width_mm"), _GREEN, "max W")
290
+
291
+ narrowable = [c for c in comps
292
+ if c.get("min_width_xy") and c["max_width_px"] > 0]
293
+ narrowest = min(narrowable, key=lambda c: c["min_width_px"],
294
+ default=None)
295
+ if narrowest:
296
+ _mark_width(overlay, narrowest["min_width_xy"],
297
+ narrowest["min_width_px"],
298
+ narrowest.get("min_width_mm"), _BLUE, "min W")
299
+ return overlay
300
+
301
+
302
+ def analyze(image_rgb: np.ndarray, threshold: float = 0.5,
303
+ mm_per_pixel: float | None = None, use_aruco: bool = False,
304
+ marker_length_mm: float = aruco_scale.DEFAULT_MARKER_LENGTH_MM) -> dict:
305
+ """Full pipeline: (optional ArUco rectify) -> segment -> measure -> overlay.
306
+
307
+ Parameters
308
+ ----------
309
+ image_rgb : input RGB image.
310
+ threshold : crack probability cutoff.
311
+ mm_per_pixel : manual scale; ignored when `use_aruco` succeeds.
312
+ use_aruco : if True, detect an ArUco marker, rectify the wall plane
313
+ and derive the scale automatically.
314
+ marker_length_mm : printed side length of the marker's black square.
315
+
316
+ The returned dict includes an `aruco` field describing what happened with
317
+ marker detection (None when `use_aruco` is False).
318
+ """
319
+ aruco_info = None
320
+ if use_aruco:
321
+ rect = aruco_scale.rectify_to_metric(image_rgb, marker_length_mm)
322
+ if rect is not None:
323
+ # Work on the rectified, fronto-parallel image: scale is now
324
+ # uniform and known, so measurements come out in real units.
325
+ image_rgb = rect["image"]
326
+ mm_per_pixel = rect["mm_per_pixel"]
327
+ aruco_info = {
328
+ "detected": True,
329
+ "marker_id": rect["marker_id"],
330
+ "marker_length_mm": marker_length_mm,
331
+ "mm_per_pixel": round(mm_per_pixel, 5),
332
+ }
333
+ else:
334
+ aruco_info = {
335
+ "detected": False,
336
+ "message": "No ArUco marker detected - reporting pixel "
337
+ "measurements. Ensure the printed marker is fully "
338
+ "visible, flat and in focus.",
339
+ }
340
+
341
+ # ML segmentation locates the crack.
342
+ mask, proba = segment(image_rgb, threshold=threshold)
343
+ # Computer vision refines the crack from intensity contrast within the
344
+ # ML region; width is then measured along the ML centreline.
345
+ cv_mask = cv_crack.segment_cv(image_rgb, mask)
346
+ measurements = measure_crack(mask, cv_mask, mm_per_pixel=mm_per_pixel)
347
+ overlay = make_overlay(image_rgb, cv_mask, measurements,
348
+ annotate_each=False)
349
+
350
+ return {"mask": mask, "cv_mask": cv_mask, "proba": proba,
351
+ "overlay": overlay, "measurements": measurements,
352
+ "aruco": aruco_info}
cv_crack.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Computer-vision crack refinement.
2
+
3
+ The ML segmenter reliably *locates* cracks but tends to over-segment their
4
+ *width* — it marks a generous "crack zone" rather than the exact dark pixels.
5
+ This module re-segments the crack *inside* the ML region using classical
6
+ intensity-contrast cues, producing a mask that is tight on the true crack
7
+ while still covering its whole length.
8
+
9
+ Method
10
+ ------
11
+ 1. Restrict work to the ML mask's neighbourhood (ROI). This rejects shadows,
12
+ stains, the ArUco marker, the hand, etc. — anything dark elsewhere.
13
+ 2. CLAHE equalisation flattens uneven lighting inside the ROI.
14
+ 3. A black-tophat operator measures, per pixel, how much darker it is than
15
+ its local surroundings — a local intensity gradient against the
16
+ background. A real crack scores high; the lighter halo the ML mask
17
+ included scores low.
18
+ 4. Otsu's threshold on that response (over ROI pixels only) separates the
19
+ true dark crack core from that halo.
20
+ 5. Only components overlapping the ML detection are kept.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import cv2
26
+ import numpy as np
27
+
28
+
29
+ def _odd(value: float) -> int:
30
+ """Nearest odd integer >= 1 (valid structuring-element / kernel size)."""
31
+ v = max(1, int(round(value)))
32
+ return v if v % 2 == 1 else v + 1
33
+
34
+
35
+ def _keep_overlapping(mask: np.ndarray, reference: np.ndarray) -> np.ndarray:
36
+ """Keep only `mask` components that overlap the `reference` mask."""
37
+ n, labels = cv2.connectedComponents((mask > 0).astype(np.uint8))
38
+ out = np.zeros_like(mask)
39
+ ref_b = reference > 0
40
+ for label in range(1, n):
41
+ comp = labels == label
42
+ if np.logical_and(comp, ref_b).any():
43
+ out[comp] = 255
44
+ return out
45
+
46
+
47
+ def segment_cv(image_rgb: np.ndarray, ml_mask: np.ndarray) -> np.ndarray:
48
+ """Refine the crack inside the ML region with intensity-contrast CV.
49
+
50
+ Parameters
51
+ ----------
52
+ image_rgb : the (rectified) RGB image the ML mask was produced from.
53
+ ml_mask : uint8 ML crack mask (0/255).
54
+
55
+ Returns
56
+ -------
57
+ uint8 mask (0/255), tighter on the true dark crack than `ml_mask`.
58
+ """
59
+ gray = cv2.cvtColor(image_rgb, cv2.COLOR_RGB2GRAY)
60
+ ml = (ml_mask > 0).astype(np.uint8)
61
+ if ml.sum() == 0:
62
+ return np.zeros(gray.shape, np.uint8)
63
+
64
+ # Crack scale: half the ML crack width, in pixels.
65
+ dist = cv2.distanceTransform(ml, cv2.DIST_L2, 5)
66
+ half_width = max(2.0, float(dist.max()))
67
+
68
+ # Search region: the ML mask plus a small safety margin, so the CV step
69
+ # can settle on the true (narrower) crack even if ML clipped an edge.
70
+ margin = _odd(max(5.0, half_width * 0.8))
71
+ roi = cv2.dilate(ml * 255, cv2.getStructuringElement(
72
+ cv2.MORPH_ELLIPSE, (margin, margin)))
73
+ roi_b = roi > 0
74
+
75
+ # Flatten lighting, then black-tophat = local darkness vs background.
76
+ gray_eq = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)).apply(gray)
77
+ ksize = _odd(max(9.0, half_width * 4.0))
78
+ kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (ksize, ksize))
79
+ blackhat = cv2.morphologyEx(gray_eq, cv2.MORPH_BLACKHAT, kernel)
80
+
81
+ # Otsu split of the contrast response, over ROI pixels only.
82
+ vals = blackhat[roi_b]
83
+ if vals.size < 20:
84
+ return np.zeros(gray.shape, np.uint8)
85
+ thr, _ = cv2.threshold(vals.reshape(-1, 1), 0, 255,
86
+ cv2.THRESH_BINARY + cv2.THRESH_OTSU)
87
+ cv_mask = ((blackhat >= max(thr, 1)) & roi_b).astype(np.uint8) * 255
88
+
89
+ # Tidy: drop isolated specks, then bridge small gaps along the crack so
90
+ # it stays continuous over its whole length.
91
+ cv_mask = cv2.morphologyEx(cv_mask, cv2.MORPH_OPEN,
92
+ cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)))
93
+ cv_mask = cv2.morphologyEx(cv_mask, cv2.MORPH_CLOSE,
94
+ cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7)))
95
+
96
+ return _keep_overlapping(cv_mask, ml)
make_marker.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generate a printable ArUco marker for wall measurement.
2
+
3
+ Run: ./venv/bin/python make_marker.py
4
+ Output: aruco_marker_60mm_A4.pdf (and a .png preview)
5
+
6
+ Print the PDF at 100% / "Actual size" (no scaling, no "fit to page"). Then
7
+ verify with a ruler that the printed black square is exactly 6.0 cm across.
8
+ If it is off, scaling was applied — reprint, or pass the real measured size
9
+ to the app's "marker size" field.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import cv2
15
+ import numpy as np
16
+ from PIL import Image, ImageDraw, ImageFont
17
+
18
+ import aruco_scale
19
+
20
+ DPI = 300
21
+ MM_PER_INCH = 25.4
22
+ MARKER_MM = aruco_scale.DEFAULT_MARKER_LENGTH_MM # 120 mm
23
+ MARKER_ID = aruco_scale.DEFAULT_MARKER_ID # 0
24
+
25
+ A4_W_MM, A4_H_MM = 210.0, 297.0
26
+
27
+
28
+ def mm_to_px(mm: float) -> int:
29
+ return int(round(mm / MM_PER_INCH * DPI))
30
+
31
+
32
+ def _font(size_px: int) -> ImageFont.FreeTypeFont:
33
+ for path in ("/System/Library/Fonts/Supplemental/Arial.ttf",
34
+ "/System/Library/Fonts/Helvetica.ttc",
35
+ "/Library/Fonts/Arial.ttf"):
36
+ try:
37
+ return ImageFont.truetype(path, size_px)
38
+ except OSError:
39
+ continue
40
+ return ImageFont.load_default()
41
+
42
+
43
+ def build_marker_image(side_px: int) -> Image.Image:
44
+ """Render the ArUco marker (6x6 modules) crisply at `side_px`."""
45
+ modules = 6 # 4x4 data + 1-module black border each side
46
+ hires = modules * 200
47
+ raw = cv2.aruco.generateImageMarker(aruco_scale.get_dictionary(),
48
+ MARKER_ID, hires)
49
+ return Image.fromarray(raw).resize((side_px, side_px), Image.NEAREST)
50
+
51
+
52
+ def main() -> None:
53
+ page = Image.new("RGB", (mm_to_px(A4_W_MM), mm_to_px(A4_H_MM)), "white")
54
+ draw = ImageDraw.Draw(page)
55
+ cx = page.width // 2
56
+
57
+ title = _font(mm_to_px(7))
58
+ body = _font(mm_to_px(4.2))
59
+ small = _font(mm_to_px(3.4))
60
+
61
+ def centered(y, text, font, fill="black"):
62
+ w = draw.textlength(text, font=font)
63
+ draw.text((cx - w / 2, y), text, font=font, fill=fill)
64
+
65
+ y = mm_to_px(15)
66
+ centered(y, "Crack Measurement - ArUco Scale Marker", title)
67
+ y += mm_to_px(11)
68
+ centered(y, "1. Print this page at 100% / Actual Size (no scaling).", body)
69
+ y += mm_to_px(6)
70
+ centered(y, "2. Tape it FLAT on the wall, beside the crack, facing the camera.", body)
71
+ y += mm_to_px(6)
72
+ centered(y, "3. Photograph wall + marker together, then upload to the app.", body)
73
+ y += mm_to_px(10)
74
+
75
+ # The marker, with a white quiet zone the printed page already provides.
76
+ side_px = mm_to_px(MARKER_MM)
77
+ marker = build_marker_image(side_px)
78
+ mx, my = cx - side_px // 2, y
79
+ page.paste(marker, (mx, my))
80
+
81
+ # Dimension callout under the marker.
82
+ yb = my + side_px + mm_to_px(4)
83
+ draw.line([(mx, yb), (mx + side_px, yb)], fill="black", width=3)
84
+ for ex in (mx, mx + side_px):
85
+ draw.line([(ex, yb - mm_to_px(2)), (ex, yb + mm_to_px(2))],
86
+ fill="black", width=3)
87
+ centered(yb + mm_to_px(3),
88
+ f"Black square = {MARKER_MM:.0f} mm ({MARKER_MM/10:.1f} cm)", body)
89
+
90
+ y = yb + mm_to_px(14)
91
+ centered(y, f"Dictionary: DICT_4X4_50 Marker ID: {MARKER_ID}", small)
92
+ y += mm_to_px(10)
93
+
94
+ # Independent 100 mm print-scale check ruler.
95
+ centered(y, "Print check - this line must measure exactly 10.0 cm:", small)
96
+ y += mm_to_px(6)
97
+ ruler_px = mm_to_px(100.0)
98
+ rx = cx - ruler_px // 2
99
+ draw.line([(rx, y), (rx + ruler_px, y)], fill="black", width=3)
100
+ for i in range(11): # cm ticks
101
+ tx = rx + mm_to_px(10.0 * i)
102
+ th = mm_to_px(3.5 if i % 5 == 0 else 2.0)
103
+ draw.line([(tx, y - th), (tx, y + th)], fill="black", width=2)
104
+ y += mm_to_px(8)
105
+ centered(y, "If it is not 10.0 cm, your printer rescaled the page - "
106
+ "reprint without scaling.", small)
107
+
108
+ # Save the PDF as a 1-bit (black/white) page: lossless CCITT compression,
109
+ # crisp marker edges, small file. The page is pure line art so nothing
110
+ # is lost. dither=NONE keeps text edges clean instead of speckled.
111
+ stem = f"aruco_marker_{MARKER_MM:.0f}mm_A4"
112
+ page.save(f"{stem}.png", dpi=(DPI, DPI))
113
+ page.convert("1", dither=Image.NONE).save(
114
+ f"{stem}.pdf", resolution=float(DPI))
115
+ print(f"Wrote {stem}.pdf and {stem}.png")
116
+ print(f"Marker: DICT_4X4_50 id={MARKER_ID} side={MARKER_MM:.0f} mm")
117
+
118
+
119
+ if __name__ == "__main__":
120
+ main()
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ torch==2.12.0
2
+ torchvision==0.27.0
3
+ segmentation-models-pytorch==0.5.0
4
+ opencv-python-headless==4.13.0.92
5
+ numpy==2.4.6
6
+ pillow==12.2.0
7
+ scikit-image==0.26.0
8
+ flask==3.1.3
9
+ gunicorn==23.0.0
run.sh ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Start the crack detection web app.
3
+ set -e
4
+ cd "$(dirname "$0")"
5
+
6
+ if [ ! -d venv ]; then
7
+ echo "Creating virtualenv..."
8
+ python3.12 -m venv venv
9
+ ./venv/bin/pip install -q -r requirements.txt
10
+ fi
11
+
12
+ exec ./venv/bin/python app.py
templates/index.html ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Crack Detection &amp; Measurement</title>
7
+ <style>
8
+ :root { --bg:#0f1419; --panel:#1a2129; --line:#2c3742; --accent:#ff6b4a;
9
+ --text:#e6edf3; --muted:#8b98a5; }
10
+ * { box-sizing:border-box; margin:0; padding:0; }
11
+ body { background:var(--bg); color:var(--text); font:15px/1.5 system-ui,sans-serif; }
12
+ header { padding:22px 28px; border-bottom:1px solid var(--line); }
13
+ header h1 { font-size:20px; font-weight:600; }
14
+ header p { color:var(--muted); font-size:13px; margin-top:3px; }
15
+ .wrap { max-width:1000px; margin:0 auto; padding:24px 28px; }
16
+ .panel { background:var(--panel); border:1px solid var(--line);
17
+ border-radius:10px; padding:20px; margin-bottom:20px; }
18
+ #drop { border:2px dashed var(--line); border-radius:10px; padding:42px;
19
+ text-align:center; cursor:pointer; transition:.15s; }
20
+ #drop:hover, #drop.over { border-color:var(--accent); background:#202935; }
21
+ #drop p { color:var(--muted); }
22
+ a { color:var(--accent); }
23
+ .note { color:var(--muted); font-size:12px; margin-top:14px; }
24
+ #status { margin-top:10px; font-size:13px; }
25
+ #status.err { color:#ff6b4a; }
26
+ #status.busy { color:var(--accent); }
27
+ figure { border:1px solid var(--line); border-radius:8px; overflow:hidden; }
28
+ figcaption { padding:8px 12px; font-size:12px; color:var(--muted);
29
+ background:var(--bg); }
30
+ figure img { width:100%; display:block; }
31
+ .cards { display:grid; grid-template-columns:repeat(3,1fr); gap:12px;
32
+ margin-top:16px; }
33
+ .card { background:var(--bg); border:1px solid var(--line); border-radius:8px;
34
+ padding:16px; text-align:center; }
35
+ .card .v { font-size:24px; font-weight:700; }
36
+ .card .k { font-size:12px; color:var(--muted); margin-top:3px;
37
+ text-transform:uppercase; letter-spacing:.04em; }
38
+ h2 { font-size:14px; margin-bottom:12px; color:var(--muted);
39
+ text-transform:uppercase; letter-spacing:.05em; }
40
+ .hidden { display:none; }
41
+ .banner { padding:10px 14px; border-radius:8px; font-size:13px;
42
+ margin-bottom:14px; }
43
+ .banner.ok { background:#16301d; border:1px solid #2c5c3a; color:#8ce0a4; }
44
+ .banner.warn { background:#332414; border:1px solid #6b4a1f; color:#e6b873; }
45
+ </style>
46
+ </head>
47
+ <body>
48
+ <header>
49
+ <h1>Crack Detection &amp; Measurement</h1>
50
+ <p>Upload a photo of a wall — the crack is segmented and measured in real units.</p>
51
+ </header>
52
+
53
+ <div class="wrap">
54
+ <div class="panel">
55
+ <div id="drop">
56
+ <p id="dropText">Drop an image here, or click to browse</p>
57
+ <input type="file" id="file" accept="image/*" class="hidden">
58
+ </div>
59
+ <p class="note">Include the printed ArUco marker in the photo so the wall
60
+ can be scaled to real centimetres.
61
+ <a href="/marker" target="_blank">Download the printable marker (PDF) →</a></p>
62
+ <p id="status"></p>
63
+ </div>
64
+
65
+ <div id="results" class="hidden">
66
+ <div id="arucoStatus"></div>
67
+ <div class="panel">
68
+ <h2>Crack measurement</h2>
69
+ <figure>
70
+ <img id="overlayImg">
71
+ <figcaption>Computer-vision segmentation (intensity-based, within the
72
+ ML-detected region) — green ⊕: widest point · blue ⊕: narrowest point</figcaption>
73
+ </figure>
74
+ <div id="cards" class="cards"></div>
75
+ </div>
76
+ </div>
77
+ </div>
78
+
79
+ <script>
80
+ const $ = id => document.getElementById(id);
81
+ const cm = mmVal => (mmVal / 10).toFixed(2); // mm -> cm
82
+
83
+ const drop = $("drop"), fileInput = $("file");
84
+ drop.onclick = () => fileInput.click();
85
+ drop.ondragover = e => { e.preventDefault(); drop.classList.add("over"); };
86
+ drop.ondragleave = () => drop.classList.remove("over");
87
+ drop.ondrop = e => {
88
+ e.preventDefault(); drop.classList.remove("over");
89
+ if (e.dataTransfer.files.length) analyze(e.dataTransfer.files[0]);
90
+ };
91
+ fileInput.onchange = () => { if (fileInput.files.length) analyze(fileInput.files[0]); };
92
+
93
+ function setStatus(msg, kind) {
94
+ $("status").textContent = msg;
95
+ $("status").className = kind || "";
96
+ }
97
+
98
+ async function analyze(file) {
99
+ if (!file.type.startsWith("image/")) { setStatus("Please choose an image file.", "err"); return; }
100
+ $("dropText").textContent = "Selected: " + file.name;
101
+ setStatus("Analyzing… segmenting and measuring the crack.", "busy");
102
+
103
+ const fd = new FormData();
104
+ fd.append("image", file);
105
+ try {
106
+ const r = await fetch("/analyze", { method:"POST", body:fd });
107
+ const data = await r.json();
108
+ if (!r.ok) throw new Error(data.error || "Analysis failed");
109
+ render(data);
110
+ setStatus("Done.", "");
111
+ } catch (err) {
112
+ setStatus(err.message, "err");
113
+ }
114
+ }
115
+
116
+ function renderAruco(a) {
117
+ const box = $("arucoStatus");
118
+ if (!a) { box.innerHTML = ""; box.className = ""; return; }
119
+ if (a.detected) {
120
+ box.className = "banner ok";
121
+ box.textContent = `ArUco marker #${a.marker_id} detected — wall rectified, `
122
+ + `scale ${a.mm_per_pixel.toFixed(4)} mm/px. Measurements are real-world.`;
123
+ } else {
124
+ box.className = "banner warn";
125
+ box.textContent = a.message;
126
+ }
127
+ }
128
+
129
+ function render(data) {
130
+ $("overlayImg").src = data.overlay;
131
+ $("results").classList.remove("hidden");
132
+ renderAruco(data.aruco);
133
+
134
+ const m = data.measurements;
135
+ const real = m.scale_known;
136
+ const s = m.summary, sm = m.summary_mm;
137
+
138
+ const cards = real
139
+ ? [["Max width", sm.max_width_mm + " mm"],
140
+ ["Min width", sm.min_width_mm + " mm"],
141
+ ["Total length", cm(sm.total_length_mm) + " cm"]]
142
+ : [["Max width", s.max_width_px + " px"],
143
+ ["Min width", s.min_width_px + " px"],
144
+ ["Total length", s.total_length_px + " px"]];
145
+ $("cards").innerHTML = cards.map(([k,v]) =>
146
+ `<div class="card"><div class="v">${v}</div><div class="k">${k}</div></div>`).join("");
147
+ }
148
+ </script>
149
+ </body>
150
+ </html>